Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Complete, precise description of how a new borg variant is added to Gotcha Force (GameCube, JPN GG4J, main.dol), as implemented in the gotcha force editor's borg/table_externalize.rs (build_add_borg_iso).

All patches are applied to a copy of the disc image — never the original.

0. The core problem

A borg is identified by borgTypeId = (class << 8) | variant, which is also its asset file name pl[CC][VV].pzz.

Internally, everything that describes a borg is split across per-class tables hardcoded in the DOL. Each table is an array of 16 base pointers (one per class); each base pointer addresses a contiguous sub-table of variantCount[class] fixed-stride records. The per-class variant count is read from a single shared array:

  • g_BorgVariantCounts @ 0x802c2a20byte[16], one variant count per class (sum = 227), shared by every borg table.

So to add one variant to a class you must grow all of these tables in lockstep and bump the count, otherwise the new variant indexes past the end of every sub-table → crash.

1. The 12 parallel tables (BORG_TABLES registry)

All are indexed by (class, variant) and all grow together:

# Table base-array VA stride Contents
1 g_BorgStatTables 0x802dc974 0x0c GF-energy cost (6 shorts, one per level)
2 g_BorgValue2Table 0x802f23e8 2 type / element index
3 g_BorgLevelStatTable 0x802f2ecc 0x0c detail-panel level stat
4 g_BorgVariantFlags 0x802dca80 1 per-variant flags
5 g_BorgGroupTable 0x802f21d8 2 encyclopedia group id
6 g_BorgLevelThresholdTables 0x802f2fd8 1 XP-curve index
7 g_BorgParamTables 0x802f1f48 0x168 HP + weapon ammo/recharge (the large one, ~81 KB)
8 g_BorgSetupTables 0x802d27dc 4 pointer to the GF_BorgSetup_CnVm function
9 g_ForceAssetTable 0x802cfb3c 4 the .pzz model AFS fid
10 g_BorgName 0x80350698 4 pointer to the name string (Shift-JIS)
11 g_BorgPartEffectTables 0x802d1b1c 4 attachment-part bone re-pin records
12 g_BorgInitAttachSelectorTables 0x802c98fc 1 init-time attach-object selector

Every accessor materializes its base array with the same shape (lis reg, hi + addi/subi base, reg, lo), which is why all 12 can be handled uniformly.

2. The key idea: clone an existing variant

The new variant is a byte-exact clone of an existing variant (clone_from). No new data is invented. Because that variant's records already live in the DOL, there is nothing to load — the process simply copies what is already there. The clone therefore inherits valid stats/HP/setup/parts/moveset/name and is a working borg immediately.

3. The boot-hook grow (the mechanism)

3.1 Why NOT the deferred AFS externalization gate

There is an alternative path that copies the tables into a runtime buffer loaded from the AFS. It has a fatal flaw for a grown variant: the new variant reads the table slot, and if the async AFS load has not published yet (mid-scene / async timing) the slot still points at the original DOL base array → the new variant indexes out of bounds → crash. It is rejected for add-a-borg.

3.2 The boot hook (synchronous, before any read)

The grow runs at a verified, post-AFS-mount, single-shot boot point, strictly before any borg table is read:

  • GROW_HOOK_VA = 0x800881a0 — originally bl 0x800881d8 (GF_AFS_LoadPostInitAudioStreams). It is replaced with a bl to the grow routine.
  • The routine re-issues the displaced bl 0x800881d8 itself first, so nothing is skipped.

Three memory regions are used (all are zero-padding "caves", checked empty before writing, so a wrong DOL can never be silently clobbered):

  • Cave A — config @ 0x800034dc: 12 descriptors of GROW_ENTRY_SIZE = 0x0c bytes each: { base_va (u32), copy_bytes (u16), stride (u16), clone_off (u16), override_fid (u16) } where copy_bytes = count[class] * stride (the original sub-table length) and clone_off = clone_from * stride (the source record offset).
  • Cave B — routine @ 0x800035b0: the grow routine (built by assemble_grow_routine).
  • The hook @ 0x800881a0.

3.3 The routine algorithm (per table)

  1. Re-issue the displaced bl 0x800881d8.
  2. Allocate copy_bytes + stride from the persistent GF global heap via GF_QueueGlobalAssetLoad @ 0x8002a878not the HSD pool.
    • RE-confirmed reason: the HSD pool allocator stores its free-list links inside freed payload, so on a scene transition the buffer's first bytes get clobbered to 0 → a null low-class base → GF_GetBorgStat reads a null pointer → crash. The GF global heap is created once at boot and never reset, so it survives every scene. This early in boot it is nearly empty (~15 MB free) → no OOM.
    • If the alloc returns 0 (OOM), the table is skipped (its base pointer stays original — never a write-through-null).
  3. Byte-copy copy_bytes bytes from base[class0] (the original sub-table) into the new buffer.
  4. Byte-copy stride bytes from base[class0] + clone_off (the clone_from record) to new + copy_bytes — this appends the new variant.
  5. override_fid (only g_ForceAssetTable): if non-zero, write it (u32) over the appended entry, so the new variant's .pzz slot points at its own copy instead of the cloned source fid.
  6. Publish: base[class0] = new (swap in the grown sub-table).
  7. Next table; then blr back to the hook.

Synchronous, no async, no scene-state wait → the grown table is live before anything reads it.

Note on class: the routine grows the class-0 slot (base[0]) of each table. The built-and-tested case is therefore class 0 → pl000d, cloned from pl0000 (Normal Ninja). The "make it real" steps below are parameterised by class; extending the grow itself to an arbitrary class means pointing each descriptor's base_va at base_array + class*4.

4. The independent, editable .pzz

The disc is 100 % full (exactly 0x57058000 = 1.4 GB GameCube capacity; afs_data.afs is the last file, with 0 internal gaps). A borg .pzz is ~1 MB. The space is reclaimed by exploiting the asset map: the game loads borgs only via the .pzz listed in g_ForceAssetTable (GF_InitForce / FUN_800415c4), so the separate pl*_mdl.arc / pl*mot.bin blocks (~60 MB) are unreferenced build artifacts.

So (build_add_borg_iso, step 1a):

  1. Extract the source .pzz (fid from the g_ForceAssetTable catalog).
  2. pick_unused_borg_asset_drops: pick enough pl*_mdl.arc / pl*mot.bin entries (highest fid first, so the fewest trailing entries relocate) to free the clone's sector-aligned size.
  3. afs_repack_drop_append: repack the AFS contiguously in place — dropped entries → TOC 0/0, survivors pack down (cumulative AFS resolution requires contiguity), the clone .pzz appended at the new tail, the Filename-Directory pointer @ 0x7FFF8 nulled — all within the existing extent, no disc grow.
  4. The copy's fid becomes the descriptor's override_fid for g_ForceAssetTable → the new variant gets its own, independently-editable topology.

Every other table keeps the plain clone (override_fid = 0).

5. Making the variant real and selectable (3 DOL patches)

  1. g_BorgVariantCounts[class]++ (@ 0x802c2a20 + class) → the game iterates the new variant.
  2. VS validity-mask bit: VS_MASK_VA 0x802f1f88 + class*8 is a per-class 64-bit mask (low word = variants 0..31); set 1 << new_variant → the variant can spawn in VS.
  3. Default-roster splice: the -1-terminated id list (pointer @ 0x8035a388, read by GF_BuildDefaultRoster) — read the live list, insert new_id before the 0xffff terminator, write the extended list into a free cave word at 0x802b00ec (.text1 tail), and repoint the list pointer at it → a new game grants the new borg.

6. The Gecko equivalent (add_borg_gecko, RAM-only)

A Gecko code cannot touch the AFS on disc, so there is no own .pzz: the new variant shares the clone's .pzz (all override_fid = 0). It emits Gecko 00/04/06 codes for:

  • 06 — the 12-table grow config @ 0x800034dc;
  • 06 — the grow routine @ 0x800035b0 (it re-issues bl 0x800881d8 itself);
  • 04 — the boot hook @ 0x800881a0bl to the routine;
  • 00 — the variant-count bump @ 0x802c2a20 + class;
  • 04 — the VS-mask bit;
  • 06 — the roster list @ 0x802b00ec + 04 — the roster-pointer repoint @ 0x8035a388.

Addresses are masked & 0x01FFFFFF to the MEM1-relative form the base codes expect.

7. Verification status

  • Tool-level VERIFIED: 96 unit tests pass; build_add_borg_iso_on_a_copy produces GF-Loader7.iso where the catalog surfaces the added (0,13) borg with its own fid (a byte-exact copy of pl0000), counts 13→14, VS-mask bit 13 set, roster pointer repointed, and the grow routine + hook + per-table descriptors landed byte-for-byte.
  • NOT yet confirmed in-game: the final proof is to boot GF-Loader7.iso in Dolphin, start a new game, and confirm that pl000d and the other borgs render (battle + garage) — which would empirically confirm that the dropped pl*_mdl.arc / pl*mot.bin are truly unused.

Summary

Add-a-borg = 12 tables grown in lockstep by a synchronous boot-hook grow (clone of an existing variant, allocated from the persistent GF heap) + an own editable .pzz reclaimed by an in-place AFS repack (dropping unused pl*_mdl/pl*mot artifacts) + 3 DOL patches (variant count, VS mask, default roster). Everything is applied to a copy of the disc.

Key addresses

Symbol VA Role
g_BorgVariantCounts 0x802c2a20 per-class variant counts (bump target)
grow config (Cave A) 0x800034dc 12 × 0x0c-byte grow descriptors
grow routine (Cave B) 0x800035b0 the synchronous grow routine
boot hook 0x800881a0 bl 0x800881d8 retargeted to the routine
GF_QueueGlobalAssetLoad 0x8002a878 persistent-heap alloc
g_ForceAssetTable 0x802cfb3c .pzz fid table (gets override_fid)
AFS FD pointer file offset 0x7FFF8 nulled after repack
VS validity mask 0x802f1f88 + class*8 spawn-enable bit
default-roster pointer 0x8035a388 repointed to the spliced list
roster list cave 0x802b00ec the extended -1-terminated id list