How to add a new borg
More actions
Complete, precise description of how a new borg variant is added to Gotcha Force (GameCube, JPN GG4J, main.dol), as implemented in the 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@0x802c2a20—byte[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— originallybl 0x800881d8(GF_AFS_LoadPostInitAudioStreams). It is replaced with ablto the grow routine.- The routine re-issues the displaced
bl 0x800881d8itself 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 ofGROW_ENTRY_SIZE = 0x0cbytes each:{ base_va (u32), copy_bytes (u16), stride (u16), clone_off (u16), override_fid (u16) }wherecopy_bytes = count[class] * stride(the original sub-table length) andclone_off = clone_from * stride(the source record offset). - Cave B — routine @
0x800035b0: the grow routine (built byassemble_grow_routine). - The hook @
0x800881a0.
3.3 The routine algorithm (per table)
- Re-issue the displaced
bl 0x800881d8. - Allocate
copy_bytes + stridefrom the persistent GF global heap viaGF_QueueGlobalAssetLoad@0x8002a878— not 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_GetBorgStatreads 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).
- 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 →
- Byte-copy
copy_bytesbytes frombase[class0](the original sub-table) into the new buffer. - Byte-copy
stridebytes frombase[class0] + clone_off(theclone_fromrecord) tonew + copy_bytes— this appends the new variant. override_fid(onlyg_ForceAssetTable): if non-zero, write it (u32) over the appended entry, so the new variant's.pzzslot points at its own copy instead of the cloned source fid.- Publish:
base[class0] = new(swap in the grown sub-table). - Next table; then
blrback 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):
- Extract the source
.pzz(fid from theg_ForceAssetTablecatalog). pick_unused_borg_asset_drops: pick enoughpl*_mdl.arc/pl*mot.binentries (highest fid first, so the fewest trailing entries relocate) to free the clone's sector-aligned size.afs_repack_drop_append: repack the AFS contiguously in place — dropped entries → TOC0/0, survivors pack down (cumulative AFS resolution requires contiguity), the clone.pzzappended at the new tail, the Filename-Directory pointer @0x7FFF8nulled — all within the existing extent, no disc grow.- The copy's fid becomes the descriptor's
override_fidforg_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)
g_BorgVariantCounts[class]++(@0x802c2a20 + class) → the game iterates the new variant.- VS validity-mask bit:
VS_MASK_VA 0x802f1f88 + class*8is a per-class 64-bit mask (low word = variants 0..31); set1 << new_variant→ the variant can spawn in VS. - Default-roster splice: the
-1-terminated id list (pointer @0x8035a388, read byGF_BuildDefaultRoster) — read the live list, insertnew_idbefore the0xffffterminator, write the extended list into a free cave word at0x802b00ec(.text1tail), 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-issuesbl 0x800881d8itself);04— the boot hook @0x800881a0→blto 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_copyproducesGF-Loader7.isowhere the catalog surfaces the added(0,13)borg with its own fid (a byte-exact copy ofpl0000),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.isoin Dolphin, start a new game, and confirm thatpl000dand the other borgs render (battle + garage) — which would empirically confirm that the droppedpl*_mdl.arc/pl*mot.binare 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
|