Club Penguin: Elite Penguin Force stores a lot of its ARM9 program in dynamically loaded overlays whose stripped ranges interleave Thumb code with literal pools, switch tables, padding and embedded pointers.
At 0x0218d9f0, the bytes 40 19 1b 02 form two legal Thumb halfwords and the little endian word 0x021b1940, but only the latter participates in execution.
Recovering the surrounding boundary therefore depends on control flow, execution state and data references rather than whether a byte sequence happens to decode.
The Nintendo DS header points to an ARM9 overlay table whose records identify the file containing each overlay and describe its runtime placement.
Overlay zero names file zero, loads at 0x020767a0 and occupies 0x13b1a0 bytes after decompression, which places the end of its file backed image at 0x021b1940.
Its record also requests 0x155e0 bytes of zero initialized storage and marks the file as compressed with a stored size of 0x000cba50 bytes.
The compressed payload, its file backed mapping and the zero filled BSS extension are related as follows:
Mapping
The decompressed payload was mapped as little endian ARMv5T at 0x020767a0 with BSS represented as uninitialized memory.
The upper bound of the imported byte image is therefore 0x021b193f, even though the overlay owns storage through 0x021c6f1f once BSS has been allocated.
A payload offset o maps to 0x020767a0 + o; addresses stored by the overlay metadata are already runtime addresses.
Seeds
Overlay zero records an initializer pointer interval from 0x021929d4 through 0x02192c68.
Parsing that interval as aligned little endian words produces 164 nonzero pointers followed by a zero terminator, and every nonzero pointer in this build is odd.
Each stored value remains a tagged code pointer throughout recovery, so 0x0218d9c5 becomes 0x0218d9c4 in Thumb state rather than an untyped aligned address.
I refer to the targets as overlay initializers because the table proves they are invoked during initialization but does not establish a C++ constructor ABI.
The imported pointer table appears as typed addresses whose displayed destinations retain the low state bit:
The first words are 0x0218d9c5, 0x0218da05 and 0x0218da21, which normalize to entries at 0x0218d9c4, 0x0218da04 and 0x0218da20 in Thumb state.
Direct call targets form the next seed class, while computed branch destinations remain blocks of their existing owner unless independent evidence promotes them. Prologue matches are retained only as a final discovery heuristic and never override metadata, call targets or an existing body assignment. The first initializer recovered from this metadata appears below, with its incoming table reference providing stronger evidence than the visible prologue:
Recovery
Recovery descends recursively over basic blocks with each work item carrying an address, instruction state, evidence source and tentative owner. Returns and confirmed tail transfers close a path while unresolved indirect transfers remain pending until table recovery or an independent reference supplies their successors.
while (!empty(worklist)) {
item = pop(worklist);
block = decode(item.address, item.state);
for (instruction in block) {
carve_pc_relative_data(instruction);
queue_direct_calls(instruction);
}
switch (block.terminator.kind) {
case CONDITIONAL_BRANCH:
queue(block.terminator.taken, item.owner);
queue(block.terminator.fallthrough, item.owner);
break;
case DIRECT_BRANCH:
queue_or_record_tail(block.terminator.target, item.owner);
break;
case RETURN:
case TAIL_TRANSFER:
close_path(item.owner);
break;
case INDIRECT_BRANCH:
defer_until_targets_are_proved(block.terminator);
break;
}
}
Accepted instruction intervals, data intervals and control flow edges accumulate until contradictory evidence forces a local repair. A newly typed literal pool clears only conflicting flow before recovery restarts from the nearest supported seeds. The owner relation is allowed to contain disjoint ranges because switch tables and alignment islands can sit between blocks of one function. Recovery stops when state aware decoding, evidence classification and local repair no longer change a supported instruction or data interval:
Literals
The function begins at 0x0218d9c4, calls 0x02057d94 and reaches pop {r3, pc} at 0x0218d9ee along every local path.
Five words immediately follow at 0x0218d9f0 through 0x0218da00, and earlier Thumb ldr instructions reference those exact addresses as PC relative literals.
Four values point into the overlay's BSS area while the fifth value is the tagged code pointer 0x020777ed.
The disassembly listing makes both sides of that argument visible at once: the return closes the executable path and incoming read references classify the following words as data:
A Thumb linear sweep beginning at 0x0218d9f0 can still produce legal looking opcodes because nearly every halfword has some decoding.
That syntactic fact loses against three stronger observations: no reachable predecessor falls into the pool, several preceding loads refer to it as data and the next metadata backed entry begins at 0x0218da04.
The boundary of ov0_init_000 is therefore 0x0218d9c4 through 0x0218d9ef, with the pool excluded and the next function beginning after the fifth word.
Dispatch
A second example at 0x02079034 shows the opposite shape, where data interrupts a function whose body continues afterward.
The routine obtains an index in r1, compares it with eleven and branches to a default path when the unsigned range check fails.
For an in range value it doubles the index, adds the current PC, loads a halfword at an additional displacement of six, sign extends that halfword and finally executes add pc, r1.
The twelve halfwords from 0x02079054 through 0x0207906a resolve to twelve computed jump destinations beginning at 0x0207906c.
The resolved dispatch and its embedded table appear as follows:
They are case blocks whose exits eventually return through the frame established by the push {r4, lr} at 0x02079034, and the surrounding routine owns both the pre table range and the post table ranges.
The recovered function body consists of [0x02079034, 0x02079053] together with [0x0207906c, 0x0207919f], leaving the halfword table outside the body.
Function ownership must therefore be represented as disjoint ranges.
The range check is also part of target validation because it establishes that exactly twelve table elements can be selected by this dispatch. Table width, index scaling, PC arithmetic, sign extension and the dominating comparison must agree before computed references are admitted into the control flow graph.
The dispatch table also exposes a narrower redirection primitive than the initializer pointers.
Each selected halfword is sign extended before being added to the architectural PC at 0x02079052, so control can be redirected to an even Thumb boundary within a signed 16 bit displacement of that instruction's PC value.
A two byte corruption of one live table entry would therefore change one case edge without disturbing the other eleven, although it can't switch to Arm state or reach an arbitrary 32 bit address in the way a tagged function pointer can.
Thunks
The thunk at 0x020f36e0 loads r3 from the literal word at 0x020f36e4 and executes bx r3 without writing a new link register.
Its stored destination is 0x0215ed21, so the processor transfers to aligned address 0x0215ed20 in Thumb state while preserving the caller's return address.
The complete body is four bytes long and the literal word is data rather than a third instruction:
The absence of a link update, preservation of the incoming link register and resolved code pointer support the tail call interpretation.
The thunk body is therefore [0x020f36e0, 0x020f36e3] and the target becomes a separate function seed at 0x0215ed20 with Thumb state retained.
Bindings
The most useful recovered data structure is not the initializer array but the native binding table at 0x021835d0.
Each eight byte entry contains an odd Thumb pointer followed by a pointer to the name exported into the embedded interpreter.
The region around 0x021835f8 exposes AddInventoryItem, RemoveInventoryItem, HasInventoryItem, ClearInventory, SubtractCoins and AddCoins as adjacent pairs.
These names turn an otherwise anonymous group of short wrappers into reliable entries and disclose the mutation surface without relying on guessed class layouts:
021835f8 0211c0a9 021a9824 AddInventoryItem
02183600 0211c359 021a9900 RemoveInventoryItem
02183608 0211c401 021a984c HasInventoryItem
02183610 0211c44d 021a977c ClearInventory
02183618 0211c485 021a974c SubtractCoins
02183620 0211c4bd 021a9480 AddCoins
The low bit on every function word is significant, so the aligned body for AddCoins begins at 0x0211c4bc rather than the stored value 0x0211c4bd.
The wrapper reads its first interpreter argument as an integer and forwards it unchanged to 0x02119be4 with the player state pointer loaded from a global owner.
It then restores the interpreter stack and returns no values, which makes the wrapper narrow enough to characterize from its complete data flow rather than from its exported name alone:
The callee computes balance - delta, clamps a negative result to zero and stores the result before scheduling the state update.
The exported name therefore hides a signed convention: a negative interpreter argument increases the stored balance while a positive argument removes coins.
A script level patch can call AddCoins(-N) without locating the balance field, while a native hook can call 0x02119be4 directly with the same signed delta.
Calling the routine is preferable to overwriting the field because the routine also emits the update that keeps the displayed and serialized state coherent.
Inventory
AddInventoryItem begins at 0x0211c0a8 and accepts a string in slot one, an optional inventory context in slot two and an optional boolean in slot three.
The wrapper resolves the context object, invokes its insertion virtual at offset 0x24 and conditionally invokes the follow-up virtual at offset 0x28 after a successful insertion.
Its boolean result returns to the interpreter while HasInventoryItem, RemoveInventoryItem and ClearInventory expose the remaining container operations.
An item patch can reuse an existing marshalled call site, redirect its binding to AddInventoryItem and replace the string reference through the same constant pool.
The fixed clothing records include story rewards, calendar gated objects and multiplayer pickups such as the Puffle Handler Hat, Magic Wand, Graduation Cap, Fish Costume and Court Jester outfit.
Award records occupy a separate inventory and include the gold and silver minigame trophies, both Ice Fishing awards and the Elite Puffle Training Diploma.
An award grant must borrow the context used by an existing award path before calling AddInventoryItem, then verify ownership through HasInventoryItem and the completion calculation.
Economy
The overlay also retains a complete coin debug menu whose strings begin at 0x0219358c and are all referenced by the routine at 0x02077d5c.
Its four entries are Increase Coins, Decrease Coins, Max Out Coins and Zero Out Coins, while Coin Amount: %i is formatted by the neighboring refresh routine at 0x02078220.
The neighboring refresh routine at 0x02078220 formats Coin Amount: %i:
Access can be restored by redirecting an existing state transition to 0x02077d5c with its expected object pointer intact, or a reachable interpreter call can be redirected to AddCoins without reconstructing the menu object.
Applying the recovered owner offset after profile initialization and fixing the balance at 999999 updates the in game status panel through the normal state path:
The catalog reads the same field before routing a successful purchase through the native inventory insertion path:
The player object owns two list headers at offsets 0x123c and 0x1244, while the coin field follows at 0x12b0 and the badge selector is consumed from 0x12ac when the interface is constructed.
Inventory nodes contain forward and backward links followed by the resolved catalog key, with clothing and awards stored in separate lists behind the same exported insertion wrapper.
The transition binding resolves a spawn key before handing control to the state switch, which permits redirection into mission-only rooms without patching the renderer or player coordinates. Scene predicates can be established before the switch when the destination expects scripted actors or mission props.
Badges
Badge presentation is selected by two switch routines at 0x020bd254 and 0x020bf7c0, each mapping values zero through five onto UI/noBadge and UI/badge1 through UI/badge5.
Their callers load the selector from player state before constructing the interface object.
Forcing the second argument to five selects the five badge asset without changing the persisted selector:
Control
The initializer interval is more than a source of function seeds because it is an array of executable destinations consumed during overlay setup.
Once the overlay has been decompressed, the first table slot lies at payload offset 0x0011c234, obtained by subtracting the mapping base 0x020767a0 from runtime address 0x021929d4.
Its original bytes are c5 d9 18 02, the little endian representation of tagged Thumb pointer 0x0218d9c5.
Replacing those four bytes redirects the first initializer call without modifying any call site or fitting a branch displacement into the existing routine.
A minimal suppression patch can target the bx lr at 0x0218da16, which is normally the final instruction of the second initializer.
The stored pointer must be 0x0218da17 rather than the aligned address because bit zero requests Thumb state at the indirect transfer.
Entering at that interior instruction returns immediately through the link register supplied by the initializer dispatcher, so the first routine is skipped without consuming stack words or corrupting callee saved registers.
The patch changes c5 d9 18 02 to 17 da 18 02 and demonstrates why an exploit oriented analysis needs instruction boundaries inside functions as well as their canonical entries.
mapping base 0x020767a0
table address 0x021929d4
payload offset 0x0011c234
original pointer c5 d9 18 02 -> 0x0218d9c5
patched pointer 17 da 18 02 -> 0x0218da17
target bytes 70 47 -> bx lr
The stored overlay is compressed, so editing the ROM file at 0x0011c234 would corrupt unrelated compressed input rather than update the pointer.
The patch has to be applied to the decompressed overlay, after which the payload is recompressed and both its filesystem entry and overlay record are serialized together.
The following script performs exactly that operation with ndspy and asserts the expected source word before writing anything, which prevents the same offset from being applied silently to a different regional build or revision:
from ndspy import code, rom
image = rom.NintendoDSRom.fromFile("club-penguin.nds")
overlays = image.loadArm9Overlays()
overlay = overlays[0]
table = 0x021929D4
offset = table - overlay.ramAddress
payload = bytearray(overlay.data)
assert payload[offset:offset + 4] == bytes.fromhex("c5 d9 18 02")
payload[offset:offset + 4] = (0x0218DA17).to_bytes(4, "little")
overlay.data = bytes(payload)
image.files[overlay.fileID] = overlay.save(compress=True)
image.arm9OverlayTable = code.saveOverlayTable(overlays)
image.saveToFile("instrumented.nds")
I verified this transformation entirely in memory by saving the modified overlay, rebuilding its table and loading the result again through the same parser.
The reloaded word at 0x0011c234 is 17 da 18 02, the compressed flag remains set and the overlay table records the compressor's resulting size.
The same initializer slot becomes a runtime control target only if another primitive can write it after decompression and before dispatch.
Injection
A useful instrumentation patch points the initializer slot at a Thumb stub in file backed padding excluded from every recovered code and data range. Since the table stores a complete code pointer, the stub has no branch displacement restriction and only needs the correct state bit. Preserving the displaced initializer gives the usual trampoline shape:
hook:
push {r4, lr}
bl record_overlay_load
bl original_initializer
pop {r4, pc}
A live patcher must publish the stub only after cleaning written data and invalidating the corresponding instruction cache lines. Function recovery supplies the code cave and state information; the initializer table supplies the final transfer.
Notes
-
Runtime roots are published through DTCM words at
0x027e00e0through0x027e00fcrather than embedded as absolute main-RAM addresses. The player owner is loaded through0x027e00f8, after which the badge selector and balance are reached atowner + 0x12acandowner + 0x12b0. This indirection remains stable when the heap allocation moves and provides a substantially better live-patch anchor than scanning for the current coin value. -
The catalog contains 131 fixed records of
0x28bytes. After materialization each record carries a resource-path pointer at+0x08, its numeric item identifier at+0x18and its price at+0x24. The intrusive inventory nodes store the resolved catalog key after their forward and backward links, so a persistent grant requires resolution through the catalog rather than copying the visible identifier directly into a list node. -
Native bindings are eight-byte records containing a tagged Thumb pointer followed by the exported name pointer.
The interval from
0x021835d0through0x02183648placesPushCamera,PopCamera,SwitchState,SetSpawnandSetupParticleEffectsbeside the inventory and economy operations. Redirecting a single interpreter name reference can therefore reach the existing camera stack, state switch or spawn resolver while retaining the wrapper's argument marshalling. -
The ABI exposes two item layers in the same table.
AddInventoryItemat tagged pointer0x0211c0a9resolves a string and dispatches through virtual slots0x24and0x28, whileAddItemandDelItemare exported separately at0x0211c165and0x0211c51d. A hook can use the catalog-aware ownership path without synthesizing the intrusive container layout. -
Badge construction is duplicated rather than shared:
0x020bd254and0x020bf7c0each select a six-way resource ladder, whosebadge1sequences begin at0x0219cfe0and0x0219d684. Replacing one selector argument can consequently spoof the HUD while leaving the profile screen and persisted field unchanged. -
The ROM retains a separate Nintendo Wi-Fi coin-transfer resource family under
UI/WifiContent/CoinUpload, including plus and minus controls as well as distinct coin-pile and money-bag objects. These references are independent of the local debug-menu strings at0x0219358cthrough0x02193608, which gives the two state constructors disjoint string signatures despite their shared economy domain.