To open a record, select a node in the graph or a title in the index.
[[17358653195271|speedcylinder global spawner]]
SpeedCylinder Global Spawner
Overview
Spawns a SpeedCylinder (Pendulum) at Player 1's position in any level, not just Level 6 (Up). Pure CE AutoAssembler script — no bass.dll proxy required.
Files
SpeedCylinderSpawn.CEA— CE AutoAssembler script (pure CEA, no Lua)
Installation
- Copy
SpeedCylinderSpawn.CEAto your Cheat Engine scripts folder - In CE: File → Load → select
SpeedCylinderSpawn.CEA - Enable the script
Usage
- Add the address
SpawnFlagto your CE address list (Advanced → Add Address, or type "SpawnFlag" in the address bar after enabling the script) - Enter a race or arena (any level works)
- Set
SpawnFlagto1— a SpeedCylinder spawns at Player 1's current position - The flag auto-resets to
0after spawning - Set to
1again to spawn another
How It Works
The script hooks Scene_UpdateBallsAndState (0x41B540) which runs every frame. When SpawnFlag is set:
- Checks if a SpeedCylinder mesh is already loaded (Board+0x4788, only set on Up Race)
- If not, loads the mesh from
levels\levelup-speedcylinderviaMeshWorld_ctor - Finds Player 1's ball in the all_balls_list and reads its position
- Allocates a Pendulum struct (0x150C bytes) and calls
Pendulum_ctor - Registers the new SpeedCylinder in the Board's mechanical objects list (Board+0x2578)
Verified Addresses (Ghidra, 2026-06-22)
| Address | Function | Convention |
|---|---|---|
| 0x41B540 | Scene_UpdateBallsAndState | __thiscall (ECX=Board) |
| 0x4BA57B | operator_new | __cdecl (RET) |
| 0x461510 | MeshWorld_ctor | __thiscall (RET 0x8, 2 params) |
| 0x436A20 | Pendulum_ctor | __thiscall (RET 0x18, 6 params) |
| 0x453810 | AthenaList_Append | __thiscall (RET 0x4, 1 param) |
| 0x4D1140 | "levels\levelup-speedcylinder" | String constant |
| 0x5341E0 | App global pointer | Runtime global |
🔗 Related Documents
[[23063636100492|global toob race object spawne]]
Global Toob Race Object Spawner
Spawns any of the 10 Toob Race objects at Player 1's position in any level.
Usage
- Set
ObjectType(1-10) to select which object to spawn:- 1 = SPINNY (rotating platform)
- 2 = LOOPER (looping track)
- 3 = GEAR (spinning gear)
- 4 = BIGGEAR (big gear)
- 5 = ROTATOR (generic rotator, uses Blockdawg1 mesh)
- 6 = SPEEDCYLINDER (swinging pendulum)
- 7 = LIFTER (lifter elevator)
- 8 = TIMEBUTTON (timed button)
- 9 = BLOCKDAWG1 (block dog enemy 1)
- 10 = BLOCKDAWG2 (block dog enemy 2)
- Set
SpawnObjectto 1 to spawn at player position - Objects update automatically via vtable[0x2C] each frame
How It Works
The script bypasses the factory chain (CreateSpinny → CreateLevelObjects → etc.)
and calls constructors directly with our own MeshWorld pointer.
Key advantage over Drawbridge/PopCylinder scripts: No JIT board slot injection needed.
All Toob constructors take the mesh pointer as the last stack parameter, so we pass
our own MeshWorld directly without touching any board+0x43xx slots.
Toob Race Board Mesh Slots
Set by LevelBoard_Toob_ctor (0x41F4B0):
| Offset | Mesh File | String Addr | Used By |
|---|---|---|---|
| board+0x436C | Levels\Level8-Spinny | 0x4D0E38 | LOOPER |
| board+0x4370 | Levels\Level8-Saw | 0x4D0E24 | GEAR |
| board+0x4374 | Levels\Level8-Fallout | 0x4D0E0C | BIGGEAR |
| board+0x4378 | Levels\Level8-Blockdawg1 | 0x4D0DF0 | ROTATOR |
| board+0x437C | Levels\Level8-Blockdawg2 | 0x4D0DD4 | BLOCKDAWG2 |
Additional slots set during LoadRaceData (not by board ctor):
- board+0x4784 = LIFTER mesh (runtime, unknown file)
- board+0x4788 = SPEEDCYLINDER mesh (runtime, unknown file)
- board+0x478C = TIMEBUTTON mesh (runtime, unknown file)
- board+0x47E0 = SPINNY mesh (runtime, unknown file)
For objects with unknown mesh files (SPEEDCYLINDER, LIFTER, TIMEBUTTON),
the script uses Level8-Spinny as a fallback mesh.
Constructor Summary
| Object | Constructor | Addr | Alloc | RET | CollisionLevel Offset |
|---|---|---|---|---|---|
| SPINNY | Rotator_ctor | 0x435940 | 0x1508 | 0x14 | +0x10D4 |
| LIFTER | Rotator_ctor_sound | 0x436920 | 0x10F4 | 0x18 | +0x10E0 |
| SPEEDCYLINDER | Pendulum_ctor | 0x436A20 | 0x150C | 0x18 | +0x10E0 |
| TIMEBUTTON | Rotator_ctor_nosound | 0x436C10 | 0x10E8 | 0x14 | +0x10E0 |
| LOOPER | Looper_ctor | 0x435800 | 0x1500 | 0x14 | +0x10D4 |
| GEAR | Gear_ctor | 0x437590 | 0x1514 | 0x20 | +0x10D4 |
| BIGGEAR | Gear_ctor | 0x437590 | 0x1514 | 0x20 | +0x10D4 |
| ROTATOR | Rotator_ctor | 0x435940 | 0x1508 | 0x14 | +0x10D4 |
| BLOCKDAWG1 | Blockdawg_ctor | 0x43C310 | 0x1154 | 0x18 | +0x10D4 |
| BLOCKDAWG2 | Blockdawg_ctor | 0x43C310 | 0x1154 | 0x18 | +0x10D4 |
Factory Chain (How the Game Creates Toob Objects)
LoadRaceData("TOOBRACE")
→ reads MESHWORLD ref objects
→ for each ref, calls factory chain:
CreateBumper (0x40FA20) → BUMPER
CreateSpinny (0x4143D0) → SPINNY (→ Rotator_ctor, mesh from board+0x47E0)
CreateSpeedCylinder (0x4117B0) → LIFTER, SPEEDCYLINDER, TIMEBUTTON
CreateMechanicalObjects (0x417FE0) → LOOPER, GEAR, BIGGEAR, ROTATOR
CreateLevelObjects (0x4121D0) → BRIDGE, TIPPER, BONK, POPCYLINDER, BLOCKDAWG, CATAPULT, GLUEBIE
Toob Race Collision Handler Events
The Toob collision handler at vtable[0x1D] (0x410020) handles:
- N:SPINNY, N:SAWTEETH, N:BUMPER — collision with rotating objects
- E:ALERTSAW2, E:BRANCH — event triggers
- PILLAR, MAGNIFYER, CLOUDSCAPE — special objects
- POPCYLINDER, TRAPDOOR — pop/door effects
- E:PEGS, E:TRAPPOP, E:NOPEGS — peg state machine
- E:HEATON, E:HEATOFF — heat/lava toggle
- E:LIMIT — boundary limit
Object Registration
All objects are registered in:
board+0x2578— active objects list (update + render)board+0x10EC— collision level listboard+0x8B0+0x18— collision dispatch listboard+0x8AC→+0x480→+0x1C— render list (post-alpha)
Notes
- BLOCKDAWG objects need a DAWGPATH ref for movement. With pathId=0 they stay stationary.
- GEAR/BIGGEAR use dual-position params (X,Y,Z + X2,Y2,Z2). Script uses same position for both.
- SPEEDCYLINDER/LIFTER/TIMEBUTTON use fallback mesh (Level8-Spinny) since their actual
mesh files are loaded at runtime via board+0x4784/0x4788/0x478C and the filenames
are not known at compile time.
🔗 Related Documents
[[24542692255965|jump_mod v22]]
jump_mod v22
Press SPACE to jump (Player 1 only, raycast ground detection).
What changed from v20
v22 adds countdown and race-end gating:
-
Countdown gate: Before allowing a jump, checks
Scene+0x3A4C
(countdown_done flag). This flag is set to 1 byScene_HandleRaceEnd
(0x41B130) when all 3 Ready/Set/Go phases complete. If 0, the game
itself blocks all input inScene_vmethod31(0x41AC70) — the jump
mod now mirrors this behavior. -
Race-end gate: Checks
ball+0x14C(freeze flag). Set to 1 by
Scene_HandleRaceEndat 0x41B40D when the race timer expires
(player touches the goal). Also checked by the game'sBall_Update
at 0x4060A1. When set, the ball is frozen and jumping is blocked.
Both gates are checked in the input thread BEFORE running the raycast,
so denied jumps don't waste a raycast call.
What changed from v17
v20 changed ground detection from a fixed epsilon to a slope-aware
threshold (radius × 1.45):
On a slope of angle θ, a straight-down raycast hits at distance r/cos(θ)
from the ball center, not r. Using radius * 1.45 covers slopes up to
45° (cos(45°) ≈ 0.707, r/0.707 ≈ 1.414r). The 1.45 factor gives a small
safety margin beyond the theoretical minimum of √2 ≈ 1.414.
Features
- Raycast ground detection: Casts a ray straight down from the ball
position using the game's ownMesh_FindClosestCollision(0x465D90).
If the hit point is withinradius × 1.45of the ball Y, the ball is
grounded and can jump. - Countdown gating: No jumping during Ready/Set/Go countdown
(Scene+0x3A4C == 0). - Race-end gating: No jumping after touching the goal
(ball+0x14C == 1). - Airborne denial: Can't jump while in the air (raycast misses).
- Edge detection: SPACE uses rising-edge detection (one jump per keypress).
- Safety checks: Won't jump if ball pointer not yet captured or
during fall/respawn.
Hook points
| Hook | Address | Original bytes | Purpose |
|---|---|---|---|
| Phase 15 cave | 0x407BB4 |
8B 4C 24 1C 8B 11 (6 bytes) |
Jump impulse application |
The input thread polls the keyboard (DIK_SPACE at KeyboardDevice+0x45)
every 16ms. On rising-edge keypress, it checks countdown/race-end gates,
then runs the raycast. If grounded, sets g_want_jump=1. The Phase 15
cave checks this flag and adds an upward impulse to ball+0x174 (Y force
accumulator) if set.
Files
jump_mod_raycast.c— C source code (BASS proxy + raycast + gates)bass.dll— Compiled DLL (MinGW cross-compiled, PE32 i386)jump_mod_v22.zip— Distribution archive
Build
i686-w64-mingw32-gcc -shared -o bass.dll jump_mod_raycast.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
🔗 Related Documents
[[32519525145467|no-pause mod v2]]
No-Pause Mod v2
Prevents the pause menu from appearing via any input method — ESC key, right-click, or Win32 message pump. The game continues running normally with no pause overlay, no physics freeze, and no camera stop.
Why v1 Didn't Work
v1 only patched one of three code paths that trigger pause. Pressing ESC still paused the game through the Win32 message pump path, and right-clicking paused through the mouse event handler path.
The Three Pause Paths
Scene_CreateGameOverMenu (0x40a920) creates the pause overlay and sets scene+0x874 = 1 (pause flag). When this flag is set, GameUpdate (0x469cf0) skips calling Scene_Update on the scene — freezing all physics and game logic.
There are three independent code paths that call Scene_CreateGameOverMenu:
Path 1: DirectInput ESC Poll (Scene_Update)
Scene_Update (0x419c00)
→ Input_CheckKeyCombo(app, 2) ; checks ESC via DirectInput
→ if pressed: Scene_CreateGameOverMenu(scene, 1)
Gating conditions: game state not in {3,4}, scene+0x220 == 0, demo timer inactive.
Path 2: Right-Click Mouse Handler (vtable[5])
App_OnMouseDown(param_3=1=right button)
→ UIWidget_HitTest → vtable[5] on Scene
→ thunk 0x4130A0 checks param_3==1, App+0x238, profile+0x95
→ if conditions met: Scene_CreateGameOverMenu
This thunk appears in 32 scene-object vtables — all scene types inherit this pause-on-right-click behavior.
Path 3: Win32 Message Pump ESC (vtable[8])
WndProc → key message dispatch → vtable[8] on Scene
→ thunk 0x40B400 checks param == 0x1B (VK_ESCAPE = 27)
→ if match: JMP Scene_CreateGameOverMenu (tail call)
This is a separate ESC detection path from Path 1. Path 1 uses DirectInput polling; Path 3 uses the Win32 message pump. Both fire on ESC press. This thunk also appears in 32 vtables.
The Patches
Three single-byte patches, one per path:
| Path | Address | Original | Patched | Effect |
|---|---|---|---|---|
| 1 — DirectInput ESC | 0x419d5b | 74 09 (JZ) |
EB 09 (JMP) |
Always skip pause creation in Scene_Update |
| 2 — Right-click | 0x4130b5 | 74 17 (JZ) |
EB 17 (JMP) |
Always skip pause in vtable[5] right-click thunk |
| 3 — Message pump ESC | 0x40b405 | 75 0D (JNZ) |
EB 0D (JMP) |
Always skip pause in vtable[8] message handler thunk |
All three convert conditional jumps to unconditional jumps, causing the pause creation code to always be skipped.
How Pause Works (for reference)
Scene_CreateGameOverMenu(0x40a920) creates aPauseMenuorPauseArenaMenuoverlay and setsscene+0x874 = 1GameUpdate(0x469cf0) iterates scene objects each frame. For each object, it checksobj[0x21d](byte at scene+0x874). When the flag is 1, it skips callingScene_Update(vtable[1]) — freezing all game logic- When the player clicks "RESUME" in the pause menu,
PauseMenu_HandleButtonClicksetsscene+0x874 = 0— unfreezing the game
Installation
- Extract the zip into your Hamsterball game folder (next to
Hamsterball.exe) - Run
install.bat - Launch the game
Uninstallation
Run uninstall.bat to restore the original bass.dll.
Technical Details
- Mod type: BASS.dll proxy (v3 lazy loader pattern)
- Patch type: Three single-byte patches — conditional → unconditional jumps
- Patch addresses: RVA 0x19d5b, 0x130b5, 0x0b405 (VA 0x419d5b, 0x4130b5, 0x40b405)
- Side effects: None — ESC and right-click are simply ignored during gameplay. No menu, no pause, no freeze.
🔗 Related Documents
[[35033317086156|universal ref loader]]
Universal Ref Loader — Design Document
Created: 2026-06-23
Session: RodentRacer investigation into ref-loading system
Purpose: Enable loading ANY object ref into ANY race level via a DLL mod, and fix the multi-instance problem for static-mesh objects.
Table of Contents
- Architecture Overview
- How Ref Loading Works
- Two Board Systems: Race vs Arena
- Factory Dispatch via vtable[33]
- Board Constructor Mesh Pre-Loading
- Object Categories: Allocating vs Static vs Configuring
- The Multi-Instance Problem
- The Mesh Dependency Problem
- The Difficulty Gate Problem
- The universal ref loader Design (Option B)
- Clone-on-Return for Static Mesh Objects (Option A)
- Complete Object Dependency Table
- Complete vtable[33] Mapping
- Complete Board Slot → Mesh File Mapping
- Key Function Addresses
- Implementation Plan
- Build & Test
1. Architecture Overview
Hamsterball loads level objects in a two-layer pipeline:
MESHWORLD file (Section 1: ref points)
↓
Scene_CreateDynamicObjects (0x40C430) — iterates ref points
↓ For each ref:
↓ calls board->vtable[33](refName, &outObj, &outCol, refEntry) at 0x40C4BA
↓
Factory Function (level-specific, via vtable[33])
↓ Matches ref name via __strnicmp
↓ Allocates object, calls constructor, sets position
↓ Returns object pointer in outObj
↓
Scene adds object to active list (+0x335), sets position from MESHWORLD data
↓ Calls object vtable[22] (Init) and vtable[21] (SetTimer)
Scene_CreateDynamicObjects (0x40C430) — Full Decompiled Flow
void __fastcall Scene_CreateDynamicObjects(int *param_1)
{
// Iterate through MESHWORLD Section 1 ref points
// For each ref (puVar4 = ref entry with name, x, y, z, ...):
local_58 = NULL; // output object pointer
local_54 = 0; // output secondary object pointer (trigger zones)
// Call the Board's factory function (vtable[33], offset +0x84)
(*(board->vtable[33]))(*puVar4, &local_58, &local_54, puVar4);
if (local_58 != NULL) {
// Object was created successfully
Gfx_ScaleX(_DAT_004cf44c - puVar4[5]); // set scale
Gfx_SetPosition(puVar4[1], puVar4[2], puVar4[3]); // set position from MW data
AthenaList_Append(scene + 0x335, local_58); // add to dynamic object list
AthenaList_Append(mw_parser + 0x1c, local_58); // add to MW object list
(*local_58->vtable[22])(); // Init/Setup
(*local_58->vtable[21])(timer); // SetTimer
if (local_54 != 0) {
AthenaList_Append(scene + 0x43B, local_54); // add trigger zone
AthenaList_Append(scene_physics + 0x18, local_54);
}
}
// Advance to next ref point
}
Key insight: The factory returns a pointer in local_58. If it's non-NULL, the scene adds it to the dynamic object list and initializes it. The position comes from the MESHWORLD ref data (puVar4[1], puVar4[2], puVar4[3]).
2. How Ref Loading Works
MESHWORLD Section 1 Ref Points
Each ref point in a MESHWORLD file contains:
- Name (string, e.g.
"CATAPULT","WATERWHEEL","N:BUMPER") - Position (3 floats: x, y, z)
- Rotation (3 floats: x, y, z)
- Material (4×4 float matrix + Power + reflection + texture)
- Extra params (passed as
param_4to the factory)
Ref Name → Factory Matching
The factory function uses __strnicmp to match ref name prefixes:
"CATAPULT"matches any ref starting with "CATAPULT" (case-insensitive)"N:"prefix refs are handled by a separate N:/E: handler at0x40C5D0"E:"prefix refs are event triggers (score, gravity, etc.)
Two Passes
- vtable[33] factory — matches bare ref names (CATAPULT, TIPPER, etc.)
- N:/E: handler — called from within the factory for
N:andE:prefixed refs
The handler is called from 25 sites within the vtable[33] factory functions.
3. Two Board Systems: Race vs Arena
Hamsterball has TWO independent Board systems, selected by App+0x237:
| Mode | App+0x237 | Board Constructors | vtable Range | Switch Function |
|---|---|---|---|---|
| Race | 1 (non-zero) | 0x422xxx | 0x4D1428–0x4D2298 | 0x426780 (jump table at 0x426AB0) |
| Arena | 0 | 0x41Cxxx | 0x4D04A8–0x4D21C0 | 0x427080 (switch at 0x427140) |
Critical: Race factories handle FAR FEWER ref types than Arena factories. Levels 1, 2, 3, 9, 10, 12, 14 use ONLY the base race factory (0x4133E0) which handles only PLATFORM, STANDS, N:BUMPER. Their level-specific objects (tippers, bridges, waterwheels) are NOT created in race mode — they're loaded via the Arena Board system or are part of static geometry.
The universal ref loader should hook at 0x40C4BA (the vtable[33] call site inside Scene_CreateDynamicObjects), which intercepts BOTH Race and Arena modes.
4. Factory Dispatch via vtable[33]
The Dispatch Call Site
At address 0x0040C4BA inside Scene_CreateDynamicObjects:
; *board is in a register, vtable[33] is at offset +0x84
mov eax, [board] ; get vtable pointer
call [eax + 0x84] ; call vtable[33] factory
; args: refName, &outObj, &outCol, refEntry
Factory Function Signature
void __thiscall FactoryFunc(
Board* this, // ECX = board pointer
const char* refName, // [ESP+4] = ref name string from MESHWORLD
void** outObj, // [ESP+8] = output: created object pointer
void** outCol, // [ESP+0xC] = output: secondary object (trigger zone)
RefEntry* refEntry // [ESP+0x10] = full ref entry (name, pos, rot, material)
);
Complete vtable[33] Mapping
Race Board Factories
| # | Race Level | Constructor | Board Vtable | Factory Addr | Refs Handled |
|---|---|---|---|---|---|
| 1 | Warm-up | 0x4224A0 | 0x4D1428 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 2 | Beginner | 0x422550 | 0x4D14F0 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 3 | Intermediate | 0x4226E0 | 0x4D15C0 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 4 | Dizzy | 0x422790 | 0x4D1680 | 0x4143D0 | SPINNY, MACE, CATAPULT, TURRET, LIFTER, FAN + base |
| 5 | Tower | 0x4228C0 | 0x4D1740 | 0x414680 | MACE, CATAPULT, TURRET, LIFTER, FAN + base |
| 6 | Up | 0x422B10 | 0x4D17F8 | 0x414A20 | LIFTER, FAN, WOBBLY + base |
| 7 | Neon | 0x424860 | 0x4D1EC8 | 0x4173B0 | NEONPLATFORM, DFLOOR, TRODE + base |
| 8 | Expert | 0x423060 | 0x4D18C8 | 0x414BD0 | FAN, WOBBLY + base |
| 9 | Odd | 0x423220 | 0x4D1980 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 10 | Toob | 0x4234E0 | 0x4D1A40 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 11 | Wobbly | 0x423690 | 0x4D1B18 | 0x415460 | WOBBLY, PILLAR, POPCYLINDER + base |
| 12 | Glass | 0x424B60 | 0x4D2048 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 13 | Sky | 0x423BF0 | 0x4D1BD8 | 0x415A30 | POPCYLINDER + base |
| 14 | Master | 0x424380 | 0x4D1C80 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 15 | Impossible | 0x424EC0 | 0x4D2298 | 0x418760 | GEAR + base |
Arena Board Factories
| # | Arena Level | Constructor | Board Vtable | Factory Addr | Refs Handled |
|---|---|---|---|---|---|
| 1 | Warm-up Arena | 0x41CA40 | 0x4D04A8 | 0x419750 | (none — NoOp) |
| 2 | Beginner Arena | 0x4200E0 | 0x4D1098 | 0x419750 | (none — NoOp) |
| 3 | Intermediate Arena | 0x41CB20 | 0x4D05A0 | 0x40A550 | BRIDGE, TIPPER, WATERWHEEL, SWIRL, GLUEBIE, SMASHER1, SMASHER2 |
| 4 | Dizzy Arena | 0x41D060 | 0x4D0890 | 0x40A5F0 | TIPPER, WATERWHEEL, SWIRL, GLUEBIE, SMASHER1, SMASHER2 |
| 5 | Tower Arena | 0x41E340 | 0x4D0A08 | 0x40D7C0 | CATAPULT, MACE, DRAWBRIDGE, WINDMILL, TRAPDOOR, CHOMPER, BONK, FAN, SAWBLADE, BRIDGE, JUDGE, BELL |
| 6 | Up Arena | 0x420390 | 0x4D11A0 | 0x4117B0 | LIFTER, SPEEDCYLINDER, TIMEBUTTON, TarBubble, BRIDGE, TIPPER, BONK, BBRIDGE1-2, POPCYLINDER, BLOCKDAWG1-2, CATAPULT |
| 7 | Neon Arena | 0x424440 | 0x4D1DF0 | 0x416910 | NEONPLATFORM, DFLOOR1-4, TRODE, N:NEONPLATFORM, E:ZOOP, E:LIGHTSOFF, E:LIGHTSON, FLICKNING, N:BUMP, N:GLASS, N:TENBONUS1 |
| 8 | Expert Arena | 0x41EA40 | 0x4D0B00 | 0x40E250 | BONK, FAN, SAWBLADE, BRIDGE, JUDGE, BELL, E:SCORE, E:BELL, LIFTER, E:GRAVITY |
| 9 | Odd Arena | 0x41ED80 | 0x4D0BC0 | 0x40EC40 | LIFTER, E:GRAVITY, WOBBLY1-7, WAVY1, N:SQUAREWOBBLY, N:WAVY, SPINNY |
| 10 | Toob Arena | 0x41F4B0 | 0x4D0E78 | 0x40FB30 | SPINNY, FALLOUT1, BLOCKDAWG1-3, E:ALERTSAW2, E:BRANCH, N:SPINNY, N:SAWTEETH, N:BUMPER, PILLAR, MAGNIFYER |
| 11 | Glass Arena | 0x41F110 | 0x4D0D38 | 0x40F420 | WOBBLY1-7, WAVY1, N:SQUAREWOBBLY, N:WAVY, SPINNY, FALLOUT1, BLOCKDAWG1-3, E:ALERTSAW2, E:BRANCH, N:SPINNY |
| 12 | Wobbly Arena | 0x424A90 | 0x4D1F90 | 0x40AD80 | SMASHER1, SMASHER2, SECRETUNLOCK, SECRET, BADBALL |
| 13 | Sky Arena | 0x41F930 | 0x4D0FC8 | 0x410AD0 | POPCYLINDER, TRAPDOOR, N:BUMPER, VAC-IN, LIFTER, SPEEDCYLINDER, TIMEBUTTON |
| 14 | Master Arena | 0x4206D0 | 0x4D12B0 | 0x4121D0 | BRIDGE, TIPPER, BONK, BBRIDGE1-2, POPCYLINDER, BLOCKDAWG1-2, CATAPULT, GLUEBIE, N:SPINNER, N:BUMPER, E:LAUNCH |
| 15 | Impossible Arena | 0x424C20 | 0x4D21C0 | 0x417FE0 | LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM, N:BOUNCE, N:ONROTATOR, N:ONGEAR |
Key insight: Levels 1, 2, 3, 9, 10, 12, 14 Race factories handle ONLY PLATFORM, STANDS. Their interesting objects (tippers, bridges, waterwheels) are NOT created in race mode by vtable[33] — they're loaded via the Arena Board system or are static geometry.
5. Board Constructor Mesh Pre-Loading
Each Board constructor loads specific mesh files into board+0x4xxx slots BEFORE the factory runs. The factory then reads these slots to get the mesh data needed by each object's constructor.
How It Works
// Inside LevelBoard_Dizzy_ctor (Dizzy Race, 0x41D060):
pvVar1 = operator_new(0x10D0);
pvVar1 = MeshWorld_ctor(pvVar1, App+0x174, "Levels\\Level3-WaterWheel");
*(void**)(board + 0x4BA8) = pvVar1; // store mesh at board+0x4BA8
pvVar1 = operator_new(0x10D0);
pvVar1 = CollisionLevel_ctorWithLevel(pvVar1, board+0x4BA8);
*(void**)(board + 0x4BAC) = pvVar1; // store collision at board+0x4BAC
The Conflict Problem
The same board+0x4xxx slot is reused for different meshes on different levels. For example:
| Board Slot | Beginner | Intermediate | Tower | Expert | Impossible | Master | Up |
|---|---|---|---|---|---|---|---|
| +0x436C | (empty) | Level2-Bridge | Level4-Catapult | (empty) | Looper | BreakBridge1 | (empty) |
| +0x4370 | (empty) | (collision) | Level4-Drawbridge | (empty) | BigGear | BreakBridge2 | (empty) |
| +0x4374 | (empty) | (empty) | YellowLink(MeshNode) | (empty) | Rotator | PopCylinder2 | (empty) |
| +0x4378 | (empty) | (empty) | Level4-Mace | Level5-Bridge | Rotator | PopCylinder | (empty) |
| +0x437C | (empty) | (empty) | Level4-Windmill | Collision | Pendulum | (empty) | (empty) |
You cannot simply "load all meshes" into board slots — you'd overwrite one mesh with another.
Solution: Just-In-Time Mesh Injection
The DLL maintains its own private array of all 46 object meshes loaded from disk. When the factory needs a specific mesh, the DLL:
- Saves the current board slot value
- Writes the correct mesh pointer into the slot
- Calls the constructor (which reads the slot)
- Restores the original board slot value
6. Object Categories: Allocating vs Static vs Configuring
All 46 verified ref types fall into three categories:
Category 1 — Allocating Objects (MULTI-INSTANCE SAFE)
The factory allocates a new object each time, calls a constructor, and appends it to the active object list (board+0x2578). Multiple instances work fine — each gets independent state. They share the pre-loaded mesh at board+0x4xxx (read-only), which is fine for rendering.
| Object | Alloc Size | Constructor | Board Mesh Slot (READ) | AthenaList | Difficulty Gate |
|---|---|---|---|---|---|
| TIPPER | 0x1104 | Tipper_ctor | +0x4394, +0x4398 | +0x2578 | YES (App+0x23C≠0) |
| BONK | 0x1200 | Bonk_ctor | (self-loads) | +0x2578 | YES (App+0x23C≠0) |
| BREAKBRIDGE1 | 0x1100 | BreakBridge_ctor | +0x5410 | +0x2578 | No |
| BREAKBRIDGE2 | 0x1100 | BreakBridge_ctor | +0x5414 | +0x2578 | No |
| POPCYLINDER | 0x10E8 | PopCylinder_ctor | +0x5420 | +0x2578, +0x5428 | No |
| BLOCKDAWG1 | 0x1154 | Blockdawg_ctor | +0x5840 | +0x2578 | YES (App+0x23C≠0) |
| BLOCKDAWG2 | 0x1154 | Blockdawg_ctor | +0x5844 | +0x2578 | YES (App+0x23C≠0) |
| CATAPULT | 0x1108 | Catapult_ctor | +0x5848 | +0x2578, +0x584C | No |
| GLUEBIE | 0x110C | Gluebie_ctor | +0x607C | +0x6080, +0x2578 | YES (App+0x23C≠0) |
| LIFTER | 0x10F4 | Rotator_ctor_sound | +0x4784 (Up) / +0x47E0 (Odd) | +0x2578 | No |
| SPEEDCYLINDER | 0x150C | Pendulum_ctor | +0x4788 | +0x2578 | No |
| TIMEBUTTON | 0x10E8 | Rotator_ctor_nosound | +0x478C | +0x2578 | No |
| LOOPER | 0x1500 | Looper_ctor | +0x436C | +0x2578 | No |
| GEAR | 0x1514 | Gear_ctor | +0x4370 | +0x2578 | No |
| BIGGEAR | 0x1514 | Gear_ctor | +0x4374 | +0x2578 | No |
| ROTATOR | 0x1508 | Rotator_ctor | +0x4378 | +0x2578 | No |
| PENDULUM | 0x1504 | Pendulum_ctor | +0x437C | +0x2578 | No |
| FAN | 0x1188 | TowerLevel_Ctor | (none — procedural) | +0x2578 | YES (App+0x23C≠0) |
| SAWBLADE | 0x111C | Sawblade_Level_Ctor | (none) | +0x2578 | YES (App+0x23C≠0) |
| BRIDGE-Expert | 0x10FC | Spinner_Level_ctor | +0x4378 | +0x2578 (conditional) | No |
| JUDGE | 0x1100 | Gear_Level_ctor | (none) | +0x4BBC | No |
| BELL | 0x10E8 | Tipper_Level_Ctor | (none) | +0x2578 | No |
| DRAWBRIDGE | 0x113C | Glass_Level_ctor | +0x4370 | +0x2578 | No |
| MACE | 0x110C | (0x438750) | +0x4378 | +0x2578 | No |
| TRAPDOOR | 0x10F8 | (0x438290) | (none) | +0x2578 | No |
| TURRET | 0x10D0 | Stands_ctor | +0x43B4 | (vtable dispatch) | No |
| SPINNY | 0x1508 | Rotator_ctor | +0x47E0 | +0x2578 | No |
| SMASHER1 | — | — | — | +0x2578 | No |
| SMASHER2 | — | — | — | +0x2578 | No |
| WAVY | — | — | — | +0x2578 | No |
| WOBBLY | — | GameLevel_ctor | — | +0x2578 | No |
Category 2 — Static Mesh Return Objects (MULTI-INSTANCE BROKEN)
The factory does NO allocation, NO constructor, NO list append. It returns the pre-loaded mesh pointer directly. Multiple refs all point to the SAME object in memory → only one renders (at the last position set).
| Object | Board Slot (returned) | Level | Description |
|---|---|---|---|
| WATERWHEEL | +0x4BA8 | Dizzy | Spinning floor mesh |
| SWIRL | +0x4BC4 | Dizzy | Swirl mesh |
| CHOMPER | +0x4390 | Tower | MeshNode visual only (no collision/game logic) |
| WINDMILL | +0x437C | Tower | CollisionLevel only (no game object/render) |
| PILLAR | (static) | Sky | Static mesh + collision |
Category 3 — Configuring Objects
The factory doesn't create anything — it moves/reconfigures the existing mesh.
| Object | Board Slot | Description |
|---|---|---|
| BRIDGE (base) | +0x436C | Configures existing bridge mesh position/collision |
7. The Multi-Instance Problem
Root Cause
When you add two WATERWHEEL refs to a Dizzy Race MESHWORLD file:
Scene_CreateDynamicObjectscallsvtable[33]for the first WATERWHEEL ref- The factory returns
board+0x4BA8(the pre-loaded mesh pointer) inlocal_58 - The scene adds it to the dynamic list, sets position A, calls Init
Scene_CreateDynamicObjectscallsvtable[33]for the second WATERWHEEL ref- The factory returns
board+0x4BA8again (THE SAME pointer) inlocal_58 - The scene adds it to the list again, sets position B, calls Init
- Result: Both list entries point to the same MeshWorld object. The second Init overwrites the first position. Only one renders (at position B).
The Fix: Clone-on-Return (Option A — CHOSEN)
When the universal ref-loader DLL detects a Category 2 object, it calls Level_CloneTree (0x466060) to create an independent copy before returning it.
// In the DLL factory hook, when matching WATERWHEEL/SWIRL/CHOMPER/WINDMILL/PILLAR:
void* mesh = *(void**)(board + boardOffset); // pre-loaded mesh
void* clone = Level_CloneTree(mesh, board); // 0x466060, thiscall
*outObj = (int)clone; // return the clone, not the original
Level_CloneTree (0x466060) — What It Does
undefined4* __thiscall Level_CloneTree(void* this, int param_1)
{
// 1. Allocate new 0x10D0-byte object (same size as MeshWorld)
undefined4* clone = operator_new(0x10D0);
// 2. Call Level_ctor with parent's D3D device
Level_ctor(clone, *(undefined4*)((int)this + 4));
// 3. Set vtable to PTR_Level_DeletingDtor2 (0x4D9068)
*clone = &PTR_Level_DeletingDtor2_004d9068;
// 4. Copy parent's SceneObject (this+0x480)
clone[0x120] = *(undefined4*)((int)this + 0x480);
clone[0x121] = 1; // flag: has scene object
// 5. Copy parent's world matrix (this+8)
clone[2] = *(undefined4*)((int)this + 8);
// 6. Copy collision flag (this+0x430)
*(char*)((int)clone + 0x10C) = *(char*)((int)this + 0x430);
// 7. Set parent (param_1 = board)
clone[0x11F] = param_1;
// 8. Copy timer pointer (param_1 + 0x434)
clone[0x10D] = *(undefined4*)(param_1 + 0x434);
// 9. Recursively clone children (spatial tree)
// For each child in this+0x424:
// Level_CloneTree(child, param_1) → append to clone's child list
return clone;
}
Alternative Options (kept for reference)
Option B — Re-load from file each time:
void* mesh = operator_new(0x10D0);
MeshWorld_ctor(mesh, *(int*)(*(int*)(board + 0x878) + 0x174), "Levels\\Level3-WaterWheel");
*outObj = (int)mesh;
Slower (re-parses file) but fully independent. Works even if board slot is empty (useful for cross-level loading).
Option C — Use Stands_ctor pattern:
void* obj = operator_new(0x10D0);
Stands_ctor(obj, *(void**)(board + 0x4BA8)); // clones spatial tree from parent
*outObj = (int)obj;
This is what the game itself uses for TURRET, MACE, DRAWBRIDGE — objects that need independent instances sharing a mesh.
8. The Mesh Dependency Problem
Problem
Each Board constructor loads specific mesh files into board+0x4xxx slots. The same slot holds different meshes on different levels. The factory's constructors READ these slots to get mesh data.
If a level's constructor didn't load a mesh into the required slot, the slot is NULL. The factory does NOT null-check — it dereferences the pointer directly, causing an access violation (crash).
Solution: Just-In-Time Mesh Injection
The universal ref-loader DLL maintains a private array of all object meshes loaded from disk. When dispatching to a factory:
- Before calling the constructor: Save the current values of all board slots the constructor will read
- Write the correct mesh pointers from the private array into those slots
- Call the constructor (which reads the slots)
- Restore the original board slot values (so the level's own objects aren't broken)
Which Objects Need Which Board Slots
| Object | Required Board Slot (READ) | Mesh File Path |
|---|---|---|
| BRIDGE (base) | +0x436C | Level2-Bridge (varies by level) |
| TIPPER | +0x4394 (mesh), +0x4398 (visual) | Level3-Tipper |
| WATERWHEEL | +0x4BA8 (static return) | Level3-WaterWheel |
| SWIRL | +0x4BC4 (static return) | Level3-Swirl |
| CHOMPER | +0x4390 (static return) | Meshes\Chomper |
| WINDMILL | +0x437C (collision only) | Level4-Windmill |
| DRAWBRIDGE | +0x4370 | Level4-Drawbridge |
| MACE | +0x4378 | Level4-Mace |
| TURRET | +0x43B4 | Level4-Turret |
| CATAPULT | +0x5848 | Level4-Catapult |
| BREAKBRIDGE1 | +0x5410 | Level10-Bridge1 |
| BREAKBRIDGE2 | +0x5414 | Level10-Bridge2 |
| POPCYLINDER | +0x5420 | Level9-PopCylinder1 |
| BLOCKDAWG1 | +0x5840 | Level8-Blockdawg1 |
| BLOCKDAWG2 | +0x5844 | Level8-Blockdawg2 |
| GLUEBIE | +0x607C | Level3-Gluebie |
| LOOPER | +0x436C | LevelImpossible-Looper |
| GEAR | +0x4370 | Gear |
| BIGGEAR | +0x4374 | BigGear |
| ROTATOR | +0x4378 | Rotator |
| PENDULUM | +0x437C | Pendulum |
| SPINNY | +0x47E0 | Level8-Spinny |
| LIFTER (Up) | +0x4784 | LevelUp-Lifter |
| SPEEDCYLINDER | +0x4788 | LevelUp-SpeedCylinder |
| TIMEBUTTON | +0x478C | LevelUp-Button |
| BRIDGE-Expert | +0x4378 | Level5-Bridge |
| BONK | (self-loads levels\level5-bonk) | Level5-Bonk |
| FAN | (none — procedural) | — |
| SAWBLADE | (none) | — |
| JUDGE | (none) | — |
| BELL | (none) | — |
| TRAPDOOR | (none) | — |
Objects That DON'T Need Pre-Loaded Meshes
These objects work immediately without any mesh injection:
- BONK — self-loads
levels\level5-bonkviaMeshWorld_ctorinsideBonk_ctor - FAN — procedural animation, no mesh at all
- SAWBLADE — self-contained (no mesh read)
- JUDGE — no mesh read (calls
Level_ctorbase) - BELL — no mesh read
- TRAPDOOR — no mesh dependency
9. The Difficulty Gate Problem
Problem
Some objects are gated behind App+0x23C != 0:
- TIPPER — only created when
App+0x23C != 0 - BONK — only created when
App+0x23C != 0 - BLOCKDAWG1/2 — only created when
App+0x23C != 0 - GLUEBIE — only created when
App+0x23C != 0 - FAN — only created when
App+0x23C != 0 - SAWBLADE — only created when
App+0x23C != 0
What App+0x23C Is
App+0x23C is a difficulty enum (not a simple boolean):
- 0 = Pipsqueak (easiest) — gated objects don't spawn
- 1 = Normal — gated objects spawn
- 2 = Frenzied/Hard — gated objects spawn
The absolute address is 0x4FD8B4 (App singleton at 0x4FD680 + 0x23C).
Solution
The DLL temporarily sets App+0x23C = 1 during factory dispatch for gated objects, then restores the original value afterward:
int originalDiff = *(int*)(app + 0x23C);
*(int*)(app + 0x23C) = 1; // force Normal difficulty
// ... call factory ...
*(int*)(app + 0x23C) = originalDiff; // restore
10. The Universal Ref Loader Design (Option B)
Overview
A bass.dll proxy that hooks the vtable[33] dispatch at 0x0040C4BA and replaces it with a universal factory that:
- Tries all 13 Arena factories in sequence (each factory's
__strnicmpwill match its own refs) - For each factory, injects the required meshes into board slots before calling
- For Category 2 objects (WATERWHEEL, etc.), clones the mesh instead of returning the same pointer
- For difficulty-gated objects, temporarily sets App+0x23C = 1
- Restores all board slots and App+0x23C after the factory returns
Hook Point
; Original at 0x0040C4BA:
call [eax + 0x84] ; board->vtable[33](refName, &outObj, &outCol, refEntry)
; DLL patches this to:
jmp universal_factory_dispatch
Universal Factory Dispatch (Pseudocode)
void universal_factory_dispatch(
Board* board, const char* refName,
void** outObj, void** outCol, RefEntry* refEntry)
{
// 1. Try the original level's factory first
original_factory(board, refName, outObj, outCol, refEntry);
if (*outObj != NULL) {
// Original factory handled it — check if it's a static-mesh return
if (is_static_mesh_object(refName)) {
// Clone it for multi-instance support
*outObj = Level_CloneTree(*outObj, board);
}
return;
}
// 2. Original factory didn't handle it — try all other factories
for (int i = 0; i < NUM_FACTORIES; i++) {
FactoryFunc* factory = factories[i];
// Check if this factory handles the ref name
if (!factory_handles_ref(factory, refName))
continue;
// Get the mesh slots this factory needs
int* slots = get_required_slots(factory);
// Save original slot values
void** saved = save_board_slots(board, slots);
// Inject correct meshes
inject_meshes(board, slots);
// Handle difficulty gate
int savedDiff = *(int*)(app + 0x23C);
if (is_difficulty_gated(refName))
*(int*)(app + 0x23C) = 1;
// Call the factory
factory(board, refName, outObj, outCol, refEntry);
// Restore slots and difficulty
restore_board_slots(board, slots, saved);
*(int*)(app + 0x23C) = savedDiff;
if (*outObj != NULL) {
// Check for static-mesh return
if (is_static_mesh_object(refName)) {
*outObj = Level_CloneTree(*outObj, board);
}
return;
}
}
// No factory handled it — return NULL (object not created)
*outObj = NULL;
}
Private Mesh Array
At board constructor time (hooked via a separate hook), the DLL loads all 46 object meshes from disk into a private array:
struct MeshEntry {
const char* refName; // "WATERWHEEL", "CATAPULT", etc.
const char* meshPath; // "Levels\\Level3-WaterWheel", etc.
void* mesh; // loaded MeshWorld*
void* collision; // loaded CollisionLevel*
int boardSlot; // which board+0x4xxx slot to inject into
};
MeshEntry meshDatabase[] = {
{"CATAPULT", "Levels\\Level4-Catapult", NULL, NULL, 0x5848},
{"TIPPER", "Levels\\Level3-Tipper", NULL, NULL, 0x4394},
{"WATERWHEEL", "Levels\\Level3-WaterWheel", NULL, NULL, 0x4BA8},
// ... all 46 entries
};
11. Clone-on-Return for Static Mesh Objects (Option A)
Which Objects Need Cloning
| Object | Board Slot | Why It Needs Cloning |
|---|---|---|
| WATERWHEEL | +0x4BA8 | Factory returns mesh pointer directly, no alloc |
| SWIRL | +0x4BC4 | Factory returns mesh pointer directly, no alloc |
| CHOMPER | +0x4390 | Factory returns MeshNode pointer, no alloc |
| WINDMILL | +0x437C | Factory returns CollisionLevel, no alloc |
| PILLAR | (static) | Returns static mesh + collision |
| BRIDGE (base) | +0x436C | Factory configures existing mesh, no alloc |
Clone Implementation
// Called after the factory returns, if the ref is a Category 2 object
void* clone_static_mesh(void* originalMesh, Board* board) {
// Level_CloneTree is at 0x466060, thiscall (ECX = originalMesh)
// It allocates 0x10D0 bytes, copies spatial tree recursively
typedef void* (__thiscall *CloneTreeFn)(void* this, int param_1);
CloneTreeFn cloneTree = (CloneTreeFn)0x466060;
return cloneTree(originalMesh, (int)board);
}
Detection Logic
bool is_static_mesh_object(const char* refName) {
return _strnicmp(refName, "WATERWHEEL", 10) == 0
|| _strnicmp(refName, "SWIRL", 5) == 0
|| _strnicmp(refName, "CHOMPER", 7) == 0
|| _strnicmp(refName, "WINDMILL", 8) == 0
|| _strnicmp(refName, "PILLAR", 6) == 0
|| _strnicmp(refName, "BRIDGE", 6) == 0; // base BRIDGE, not Expert
}
12. Complete Object Dependency Table
All 46 Verified Ref Types (from MESHWORLD binary parsing)
| Object | Factory | Alloc Size | Constructor | Board Mesh Slot (READ) | AthenaList(s) | Difficulty Gate | Multi-Instance? | Needs Clone? |
|---|---|---|---|---|---|---|---|---|
| BRIDGE (base) | CreateLevelObjects | N/A | None (configures mesh) | +0x436C | — | No | NO | YES |
| TIPPER | CreateLevelObjects | 0x1104 | Tipper_ctor | +0x4394, +0x4398 | +0x2578 | YES | YES | No |
| BONK | CreateLevelObjects/CreateExpertLevelObjects | 0x1200 | Bonk_ctor | (self-loads) | +0x2578 | YES | YES | No |
| BREAKBRIDGE1 | CreateLevelObjects | 0x1100 | BreakBridge_ctor | +0x5410 | +0x2578 | No | YES | No |
| BREAKBRIDGE2 | CreateLevelObjects | 0x1100 | BreakBridge_ctor | +0x5414 | +0x2578 | No | YES | No |
| POPCYLINDER | CreateLevelObjects | 0x10E8 | PopCylinder_ctor | +0x5420 | +0x2578, +0x5428 | No | YES | No |
| BLOCKDAWG1 | CreateLevelObjects | 0x1154 | Blockdawg_ctor | +0x5840 | +0x2578 | YES | YES | No |
| BLOCKDAWG2 | CreateLevelObjects | 0x1154 | Blockdawg_ctor | +0x5844 | +0x2578 | YES | YES | No |
| CATAPULT | CreateLevelObjects | 0x1108 | Catapult_ctor | +0x5848 | +0x2578, +0x584C | No | YES | No |
| GLUEBIE | CreateLevelObjects | 0x110C | Gluebie_ctor | +0x607C | +0x6080, +0x2578 | YES | YES | No |
| LIFTER | CreateUpLevelObjects/CreateLifter | 0x10F4 | Rotator_ctor_sound | +0x4784 or +0x47E0 | +0x2578 | No | YES | No |
| SPEEDCYLINDER | CreateUpLevelObjects | 0x150C | Pendulum_ctor | +0x4788 | +0x2578 | No | YES | No |
| TIMEBUTTON | CreateUpLevelObjects | 0x10E8 | Rotator_ctor_nosound | +0x478C | +0x2578 | No | YES | No |
| LOOPER | CreateMechanicalObjects | 0x1500 | Looper_ctor | +0x436C | +0x2578 | No | YES | No |
| GEAR | CreateMechanicalObjects | 0x1514 | Gear_ctor | +0x4370 | +0x2578 | No | YES | No |
| BIGGEAR | CreateMechanicalObjects | 0x1514 | Gear_ctor | +0x4374 | +0x2578 | No | YES | No |
| ROTATOR | CreateMechanicalObjects | 0x1508 | Rotator_ctor | +0x4378 | +0x2578 | No | YES | No |
| PENDULUM | CreateMechanicalObjects | 0x1504 | Pendulum_ctor | +0x437C | +0x2578 | No | YES | No |
| FAN | CreateExpertLevelObjects | 0x1188 | TowerLevel_Ctor | (none) | +0x2578 | YES | YES | No |
| SAWBLADE | CreateExpertLevelObjects | 0x111C | Sawblade_Level_Ctor | (none) | +0x2578 | YES | YES | No |
| BRIDGE-Expert | CreateExpertLevelObjects | 0x10FC | Spinner_Level_ctor | +0x4378 | +0x2578 (conditional) | No | YES | No |
| JUDGE | CreateExpertLevelObjects | 0x1100 | Gear_Level_ctor | (none) | +0x4BBC | No | YES | No |
| BELL | CreateExpertLevelObjects | 0x10E8 | Tipper_Level_Ctor | (none) | +0x2578 | No | YES | No |
| DRAWBRIDGE | CreateTowerObjects | 0x113C | Glass_Level_ctor | +0x4370 | +0x2578 | No | YES | No |
| MACE | CreateTowerObjects | 0x110C | (0x438750) | +0x4378 | +0x2578 | No | YES | No |
| TRAPDOOR | CreateTowerObjects | 0x10F8 | (0x438290) | (none) | +0x2578 | No | YES | No |
| TURRET | CreateTowerObjects | 0x10D0 | Stands_ctor | +0x43B4 | (vtable) | No | PARTIAL | No |
| CHOMPER | CreateTowerObjects | NONE | NONE (static) | +0x4390 | — | No | NO | YES |
| WINDMILL | CreateTowerObjects | N/A | CollisionLevel only | +0x437C | — | No | NO | YES |
| WATERWHEEL | CreateDizzyObjects | NONE | NONE (static) | +0x4BA8 | — | No | NO | YES |
| SWIRL | CreateDizzyObjects | NONE | NONE (static) | +0x4BC4 | — | No | NO | YES |
| SPINNY | CreateSpinny/CreateMechanicalObjects | 0x1508 | Rotator_ctor | +0x47E0 | +0x2578 | No | YES | No |
| SMASHER1 | CreateWobblyObjects | — | — | — | +0x2578 | No | YES | No |
| SMASHER2 | CreateWobblyObjects | — | — | — | +0x2578 | No | YES | No |
| WAVY | CreateOddObjects | — | — | — | +0x2578 | No | YES | No |
| WOBBLY | CreateOddObjects/CreateUpObjects | — | GameLevel_ctor | — | +0x2578 | No | YES | No |
| PILLAR | CreateSkyObjects | N/A | NONE (static) | (static) | — | No | NO | YES |
| SIGN-TARPIT | (separate dispatch) | — | — | — | — | No | YES | No |
| TARBUBBLE | CreateUpLevelObjects | — | — | — | — | No | YES | No |
| LAUNCH | (separate dispatch) | — | — | — | — | No | YES | No |
| MOUSETRAP | (separate dispatch) | — | — | — | — | No | YES | No |
| NEONPLATFORM | CreateNeonObjects | — | — | +0x4374 | +0x2578 | No | YES | No |
| DFLOOR1-4 | CreateNeonObjects | — | — | +0x4378-0x4384 | +0x2578 | No | YES | No |
| TRODE | CreateNeonObjects | — | — | +0x4388 | +0x2578 | No | YES | No |
| FLICKNING | CreateNeonObjects | — | — | — | +0x2578 | No | YES | No |
| POPCYLINDER (Sky) | CreateSkyObjects | 0x10E8 | PopCylinder_ctor | +0x5420 | +0x2578, +0x5428 | No | YES | No |
13. Complete vtable[33] Mapping
See Section 4 above for the full Race and Arena vtable[33] mapping tables.
14. Complete Board Slot → Mesh File Mapping
| Board Offset | Type | Description | Used By Levels |
|---|---|---|---|
| +0x2578 | AthenaList | Active game objects list (ALL objects) | All factories |
| +0x436C | MeshWorld* | Bridge/Tipper/Catapult/Spinny/Looper mesh (varies) | Dizzy, Tower, Expert, Master, Impossible |
| +0x4370 | CollisionLevel*/MeshWorld* | Collision for +0x436C / or secondary mesh | Dizzy, Tower, Expert, Master |
| +0x4374 | MeshWorld* | Gluebie (Dizzy) / 2PBridge (Master) / Fallout (Toob) | Dizzy, Master, Toob |
| +0x4378 | AthenaList*/MeshWorld* | Dizzy: AthenaList / Tower: Mace / Expert: Bridge / Toob: Blockdawg1 | Dizzy, Tower, Expert, Toob |
| +0x437C | CollisionLevel*/MeshWorld* | Dizzy: collision / Tower: Windmill / Expert: collision | Dizzy, Tower, Expert |
| +0x4380 | AthenaList | Expert: Spinner bridge list 1 | Expert |
| +0x4388 | float | Master: 0x42340000 (40.0f) | Master |
| +0x438C | Vec3List[8] | Toob: vector array (0x418 × 8) | Toob |
| +0x4390 | MeshNode* | Tower: Chomper visual mesh | Tower |
| +0x4394 | MeshWorld* | Tipper mesh | Dizzy, Master |
| +0x4398 | CollisionLevel* | Tipper visual/collision | Dizzy, Master |
| +0x439C | Vec3List[4] | Master: vector array (0x418 × 4) | Master |
| +0x43A0-A8 | float[3] | Tower: zero-init (0, 0, 0) | Tower |
| +0x43B4 | MeshWorld* | Tower: Turret mesh | Tower |
| +0x43B8 | AthenaList | Tower: object list | Tower |
| +0x4784 | MeshWorld* | Up: Lifter mesh | Up |
| +0x4788 | MeshWorld* | Up: SpeedCylinder mesh | Up |
| +0x478C | MeshWorld* | Up: TimeButton mesh | Up |
| +0x4790 | AthenaList | Dizzy: secondary list | Dizzy |
| +0x4798 | AthenaList | Expert: Spinner bridge list 2 | Expert |
| +0x47D0 | AthenaList | Tower: list 2 | Tower |
| +0x47E0 | MeshWorld* | Spinny/Gear/Lifter mesh | Impossible, Spinny, Lifter |
| +0x47E4 | AthenaList | Tower Arena: list | Tower Arena |
| +0x4BA8 | MeshWorld* | Dizzy: WaterWheel mesh | Dizzy |
| +0x4BAC | CollisionLevel* | Dizzy: WaterWheel collision | Dizzy |
| +0x4BC4 | MeshWorld* | Dizzy: Swirl mesh | Dizzy |
| +0x4BC8 | CollisionLevel* | Dizzy: Swirl collision | Dizzy |
| +0x4BE8 | AthenaList | Tower: list 3 | Tower |
| +0x4FD4 | void* | Expert: BELL object pointer | Expert |
| +0x5000 | AthenaList | Tower: list 4 | Tower |
| +0x540C | void* | Bonk object pointer | Dizzy, Master |
| +0x5410 | MeshWorld* | BreakBridge1 mesh | Master |
| +0x5414 | MeshWorld* | BreakBridge2 mesh | Master |
| +0x5418 | void* | BreakBridge1 object pointer | Master |
| +0x541C | void* | BreakBridge2 object pointer | Master |
| +0x5420 | MeshWorld* | PopCylinder mesh | Master, Sky |
| +0x5424 | MeshWorld* | PopCylinder2 mesh (Master only) | Master |
| +0x5428 | AthenaList | PopCylinder list | Master |
| +0x5840 | MeshWorld* | BlockDawg1 mesh | Master, Toob |
| +0x5844 | MeshWorld* | BlockDawg2 mesh | Master, Toob |
| +0x5848 | MeshWorld* | Catapult mesh | Master |
| +0x584C | AthenaList | Catapult list | Master |
| +0x607C | MeshWorld* | Gluebie mesh | Master |
| +0x6080 | AthenaList | Gluebie list | Master |
| +0x868 | char* | Board display name string | All |
| +0x870 | int | Race ID from App+0x14+0x1DC | All |
| +0x878 | App* | App pointer | All |
| +0x8AC | Scene* | Scene pointer | All |
| +0x4344 | char* | Display/theme string | All |
15. Key Function Addresses
| Address | Function | Description |
|---|---|---|
| 0x40C430 | Scene_CreateDynamicObjects | Iterates MESHWORLD ref points, calls factory |
| 0x40C4BA | (call site) | vtable[33] dispatch call inside Scene_CreateDynamicObjects |
| 0x40C5D0 | N:/E: Handler | Handles N: and E: prefixed ref names |
| 0x4133E0 | BaseFactory | Base race factory (PLATFORM, STANDS) |
| 0x419750 | NoOpFactory | Warm-up/Beginner Arena factory (no-op) |
| 0x40A550 | CreateBeginnerObjects | Beginner Arena factory |
| 0x40A5F0 | CreateDizzyObjects | Dizzy Arena factory |
| 0x40D7C0 | CreateTowerObjects | Tower Arena factory |
| 0x4117B0 | CreateUpLevelObjects | Up Arena factory |
| 0x416910 | CreateNeonObjects | Neon Arena factory |
| 0x40E250 | CreateExpertLevelObjects | Expert Arena factory |
| 0x40EC40 | CreateOddObjects | Odd Arena factory |
| 0x40FB30 | CreateToobObjects | Toob Arena factory |
| 0x40F420 | CreateGlassObjects | Glass Arena factory |
| 0x40AD80 | CreateWobblyObjects | Wobbly Arena factory |
| 0x410AD0 | CreateSkyObjects | Sky Arena factory |
| 0x4121D0 | CreateLevelObjects | Master Arena factory (most inclusive) |
| 0x417FE0 | CreateMechanicalObjects | Impossible Arena factory |
| 0x4143D0 | CreateSpinny | Dizzy Race factory |
| 0x414680 | CreateTowerRaceObjects | Tower Race factory |
| 0x414A20 | CreateUpRaceObjects | Up Race factory |
| 0x414BD0 | CreateExpertRaceObjects | Expert Race factory |
| 0x4173B0 | CreateNeonRaceObjects | Neon Race factory |
| 0x415460 | CreateWobblyRaceObjects | Wobbly Race factory |
| 0x415A30 | CreateSkyRaceObjects | Sky Race factory |
| 0x418760 | Scene_CreateObject_Gear | Impossible Race factory |
| 0x466060 | Level_CloneTree | Clones a Level/MeshWorld object (thiscall) |
| 0x461510 | MeshWorld_ctor | Creates MeshWorld from file (alloc + parse) |
| 0x465080 | CollisionLevel_ctorWithLevel | Creates CollisionLevel from MeshWorld |
| 0x462850 | Stands_ctor | Base constructor — clones SpatialTree from parent |
| 0x453810 | AthenaList_Append | Append object to AthenaList |
| 0x453210 | AthenaList_Init | Initialize an AthenaList |
| 0x4BA57B | operator_new | Jump to malloc (0x10D0 = MeshWorld size) |
| 0x4C7677 | __strnicmp | Case-insensitive string compare |
| 0x4FD680 | App singleton | Global App object |
| 0x4FD8B4 | App+0x23C | Difficulty enum (0=Pipsqueak, 1=Normal, 2=Frenzied) |
16. Implementation Plan
Phase 1: Build the Universal Factory Hook
- Create a
bass.dllproxy that patches0x0040C4BAwith a JMP to the DLL's dispatch function - The dispatch function intercepts the factory call and tries all 13 Arena factories
- For each factory attempt, save/restore board slots and inject meshes
Phase 2: Just-In-Time Mesh Injection
- Build a mesh database mapping all 46 ref names → mesh file paths + board slot offsets
- At board constructor time, load all meshes from disk into a private array
- During factory dispatch, inject the correct mesh into the board slot before calling the constructor
Phase 3: Clone-on-Return for Static Mesh Objects
- Detect Category 2 objects (WATERWHEEL, SWIRL, CHOMPER, WINDMILL, PILLAR, BRIDGE-base)
- After the factory returns a static mesh pointer, call
Level_CloneTree(0x466060) to clone it - Return the clone instead of the original
Phase 4: Difficulty Gate Bypass
- Detect difficulty-gated objects (TIPPER, BONK, BLOCKDAWG, GLUEBIE, FAN, SAWBLADE)
- Before calling the factory, save
App+0x23Cand set it to 1 - After the factory returns, restore
App+0x23C
Phase 5: Crash Test
- Compile with MinGW:
i686-w64-mingw32-gcc -shared -o bass.dll mod.c -lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc -Wl,--add-stdcall-alias - Crash test via hbtestd or Wine/Xvfb: launch game, wait 35s, check if process alive
- Copy to
mods/universal-ref-loader/with README
17. Build & Test
Build Command
i686-w64-mingw32-gcc -shared -o bass.dll universal_ref_loader.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Crash Test
# Via hbtestd MCP (preferred):
mcp_hbtestd_test_dll_mod(path="universal_ref_loader.zip", target_dll="bass.dll", timeout=10)
# Via Wine/Xvfb:
DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 timeout 35 wine Hamsterball.exe
# Check if process is alive after 35s (catches stack corruption, wrong hook addresses)
Deliverables
mods/universal-ref-loader/bass.dll— compiled DLLmods/universal-ref-loader/universal_ref_loader.c— source codemods/universal-ref-loader/README.md— documentationmods/universal-ref-loader/hamsterball-universal-ref-loader.zip— packaged mod- Update
mods/README.mdcatalog
Appendix: Existing Related Files
| File | Description |
|---|---|
docs/REF_LOADING_SYSTEM.md |
Original ref-loading system documentation (needs update with clone findings) |
docs/VERIFIED_REFS_BY_LEVEL.md |
Verified 46 unique ref types from MESHWORLD binary parsing |
docs/objects/OBJECT_FACTORY_SYSTEM.md |
Factory system overview |
docs/objects/LEVEL_LOCKED_OBJECTS.md |
Level-locked objects and their board slot dependencies |
docs/objects/TOWER_OBJECT_ANALYSIS.md |
Dizzy/Tower factory analysis with spawnability verdicts |
docs/gameplay/ARENA_HAZARD_SYSTEM.md |
Arena hazard system (difficulty gating) |
analysis/factory_objects_comprehensive.md |
Comprehensive factory object analysis with all board offsets |
analysis/ghidra/decompilations/scene/Scene_CreateDynamicObjects_0040c430.c |
Full decompilation of the dispatch loop |
analysis/ghidra/decompilations/batch_auto/Level_CloneTree_0x00466060.c |
Full decompilation of the clone function |
analysis/ghidra/decompilations/batch_auto/Stands_ctor_0x00462850.c |
Stands_ctor (base constructor with SpatialTree clone) |
mods/universal-ref-loader/ |
Existing universal ref loader mod (needs rebuild with new design) |
🔗 Related Documents
[[35033317086156|universal ref loader]]
Universal Ref Loader — Hamsterball DLL Mod v3
What It Does
This mod patches the game's level-object dispatch system to allow any object ref to be loaded into any level. Normally, each level only loads refs that its own Board vtable[33] factory recognizes (e.g. SpeedCylinder only in Up levels, Bonk only in Expert/Master levels, Gears only in Impossible level).
The mod hooks the vtable[33] dispatch at 0x0040C4BA (inside Scene_CreateDynamicObjects at 0x40C430) and replaces it with a universal factory that:
- Tries the original factory first — preserves normal behavior for all existing refs
- Falls through to all 13 Arena factories if the original didn't handle the ref
- JIT mesh injection — if a factory needs a board mesh slot that's NULL (not loaded by the current level), loads the mesh from disk on the fly
- Clones static-mesh objects (WATERWHEEL, SWIRL, BRIDGE-base) via
Level_CloneTreefor multi-instance support - Bypasses difficulty gates — temporarily sets
App+0x23C = 1(Normal) for gated objects (TIPPER, BONK, BLOCKDAWG, GLUEBIE, FAN, SAWBLADE, MACE) - Safety checks board slots — skips factories whose required mesh slots can't be filled (prevents crashes)
How to Use
- Rename original
bass.dlltobass_real.dll(for audio passthrough) - Copy this mod's
bass.dllto your Hamsterball game directory - Add ref names to MESHWORLD Section 1 in any level file
- The mod will try all Arena factories to create the object, loading meshes from disk as needed
JIT Mesh Injection
When a factory reads a board+0x4xxx slot that's NULL (because the current level's Board constructor didn't load that mesh), the mod:
- Looks up the board offset in the mesh database (maps offsets → file paths)
- Calls
MeshWorld_ctor(mem, d3dDevice, path)to load the mesh from disk - Optionally calls
CollisionLevel_ctorWithLevel(mem, mesh)for collision - Writes the loaded mesh pointer into the board slot
- Calls the factory (which reads the slot)
- Restores the original NULL value after the factory returns
Meshes are cached so each file is only loaded once per session.
Mesh Database (verified from decompiled board constructors)
| Board Slot | Mesh File | Collision? | Used By |
|---|---|---|---|
| +0x436C | Level3-Tipper / Level4-Catapult / LevelImpossible-Looper | +0x4370 | Dizzy, Tower, Impossible |
| +0x4370 | Level4-Drawbridge / LevelImpossible-Gear | — | Tower, Impossible |
| +0x4374 | Level3-Gluebie / LevelImpossible-BigGear | — | Dizzy, Impossible |
| +0x4378 | Level4-Mace / Level5-Bridge / LevelImpossible-Rotator | — | Tower, Expert, Impossible |
| +0x437C | Level4-Windmill / LevelImpossible-Pendulum | — | Tower, Impossible |
| +0x4394 | Level3-Tipper | +0x4398 | Master |
| +0x4BA8 | Level3-WaterWheel | +0x4BAC | Dizzy |
| +0x4BC4 | Level3-Swirl | +0x4BC8 | Dizzy |
| +0x4784 | LevelUp-Lifter | — | Up |
| +0x4788 | LevelUp-SpeedCylinder | — | Up |
| +0x478C | LevelUp-Button | — | Up |
| +0x47E0 | Level8-Spinny | — | Toob/Odd/Glass |
| +0x5410 | Level10-Bridge1 | — | Master |
| +0x5414 | Level10-Bridge2 | — | Master |
| +0x5420 | Level9-PopCylinder1 | — | Master, Sky |
| +0x5840 | Level8-BlockDawg1 | — | Master, Toob |
| +0x5844 | Level8-BlockDawg2 | — | Master, Toob |
| +0x5848 | Level4-Catapult | — | Master |
| +0x607C | Level3-Gluebie | — | Master |
Verified Features
| Feature | Status | Details |
|---|---|---|
| Hook point | ✅ Verified | CALL [EAX+0x84] at 0x0040C4BA, 6 bytes FF 90 84 00 00 00 |
| Factory addresses | ✅ Verified | All 30 factories (15 Arena + 15 Race) confirmed via vtable[33] reads |
| Level_CloneTree | ✅ Verified | __thiscall at 0x466060, allocs 0x10D0, recursive spatial tree clone |
| MeshWorld_ctor | ✅ Verified | __thiscall at 0x461510, params: (mem, d3dDevice, path) |
| CollisionLevel_ctor | ✅ Verified | __thiscall at 0x465080, params: (mem, sourceMesh) |
| Board slot offsets | ✅ Verified | All offsets cross-referenced from decompiled factory code |
| Difficulty gate | ✅ Verified | board+0x878 → App+0x23C != 0 confirmed in 7 factory handlers |
| D3D device path | ✅ Verified | *(board+0x878) + 0x174 (App+0x174) |
| BASS proxy | ✅ v3 pattern | Lazy loader, no DllMain deadlock, stubs if bass_real.dll missing |
| Crash test | ✅ Passed | 14.12s runtime, no crash (hbtestd) |
Static-Mesh Cloning
Objects like WATERWHEEL and SWIRL return the same board slot pointer for every ref — only one instance renders. This mod calls Level_CloneTree to create independent copies:
| Object | Board Slot | Clone Status |
|---|---|---|
| WATERWHEEL | +0x4BA8 | ✅ Cloned |
| SWIRL | +0x4BC4 | ✅ Cloned |
| BRIDGE (base) | +0x436C | ✅ Cloned |
| WINDMILL | +0x437C | ⚠️ Not cloned (complex: creates CollisionLevel + attaches) |
Factory Dispatch Order
Factories are tried in order of inclusiveness (most ref types first):
- Expert — BONK, FAN, SAWBLADE, BRIDGE, JUDGE, BELL (no mesh deps, self-loading)
- Wobbly — SMASHER1, SMASHER2 (configuring only)
- Master — BRIDGE, TIPPER, BONK, BBRIDGE1-2, POPCYLINDER, BLOCKDAWG1-2, CATAPULT, GLUEBIE
- Tower — CATAPULT, MACE, DRAWBRIDGE, WINDMILL, TRAPDOOR, CHOMPER, TURRET
- Impossible — LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM
- Up — LIFTER, SPEEDCYLINDER, TIMEBUTTON
- Dizzy — TIPPER, WATERWHEEL, SWIRL, GLUEBIE
- Beginner — BRIDGE (base)
- Neon, Odd, Toob, Glass, Sky — level-specific objects
Limitations
- WINDMILL: Returns static mesh + creates CollisionLevel with attach — not cloned yet (complex multi-object creation).
- N:/E: prefixed refs: Handled by the original factory's N:/E: handler, not by the universal dispatch.
- Slot ambiguity: Some board offsets map to different mesh files on different levels (e.g. 0x436C = Tipper on Dizzy but Catapult on Tower). The mesh DB tries all entries; the first that loads successfully is used.
Build
i686-w64-mingw32-gcc -shared -o bass.dll universal_ref_loader.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Technical Details
- Hook point:
0x0040C4BA(CALL [EAX+0x84]→CALL universal_factory+ NOP) - Calling convention:
__thiscall(ECX=board), 4 stack args - JIT mesh loading:
MeshWorld_ctor(0x461510) +CollisionLevel_ctorWithLevel(0x465080) - D3D device:
*(board+0x878) + 0x174(App+0x174) - BASS proxy: v3 lazy loader pattern (LoadLibraryA on first BASS call, not in DllMain)
- All addresses verified via GhidraMCP decompilation + vtable memory reads (June 2026)
See docs/UNIVERSAL_REF_LOADER_DESIGN.md for the complete reverse engineering analysis.
🔗 Related Documents
[[37189490095032|reverse engineering playbook]]
Reverse Engineering Playbook
A program-agnostic guide for analyzing compiled binaries. The Hamsterball project is used as a running example, but the methodology applies to any compiled program (games, applications, malware, firmware).
Quick Start
| # | File | What it covers |
|---|---|---|
| 1 | 00-MINDSET.md | How to approach an unknown binary |
| 2 | 01-TARGETS-AND-TOOLS.md | PE, ELF, Mach-O; static vs dynamic tools |
| 3 | 02-BINARY-RECON.md | Strings, imports, exports, sections, entropy |
| 4 | 03-GHIDRA-SETUP.md | Import and auto-analyze any binary |
| 5 | 04-NAMING-STRATEGY.md | How to name unknown functions and data |
| 6 | 05-DECOMPILATION.md | Reading and cleaning decompiled output without distorting it |
| 7 | 06-CALLING-CONVENTIONS.md | cdecl, stdcall, fastcall, thiscall, x64, ARM64 |
| 8 | 07-MEMORY-LAYOUT.md | Recovering structs and globals |
| 9 | 08-OOP-PATTERNS.md | Vtables, RTTI, constructors, virtual dispatch |
| 10 | 09-DYNAMIC-ANALYSIS.md | Debuggers, hooking, tracing |
| 11 | 10-SUBSYSTEM-PATTERNS.md | Rendering, input, audio, file formats |
| 12 | 11-VERIFICATION.md | Proving your findings are right |
| 13 | 12-TROUBLESHOOTING.md | Common dead ends and how to escape |
| 14 | case-studies/hamsterball.md | How this playbook was applied to Hamsterball |
Running Example
Throughout this playbook, Hamsterball.exe (PE32, MSVC 2003, D3D8 + DInput8 + BASS) illustrates each step. See the case study for the full project-specific details.
🔗 Related Documents
[[39040799821588|ball object]]
Ball Object — Complete Modder's Reference
Verified via direct Ghidra decompilation of
Hamsterball.exe(Athena engine, PE32 i386).
All offsets below were extracted from the live decompiled code via the GhidraMCP headless server.
This document replaces / supplementsBALL_OBJECT.mdwith authoritative, source-verified data.
Quick Stats
| Property | Value |
|---|---|
| Primary update function | Ball_Update @ 0x00405E00 |
| Constructor | Ball_ctor2 @ 0x004039E0 |
| Total struct size | 0x0C98 bytes (3,224 bytes) |
| Physics body (nested) | CollisionMesh @ this + 0x1A4 (size 0xCB0) |
| VTable (Ball) | 0x004CF3A0 |
Table of Contents
- Position & Velocity
- Ball-Related Functions (Complete List)
- What You Can Do With the [[39040799821588|ball object]]
- Modding Recipes
- Verified Memory Layout (Key Fields)
Position & Velocity
Verified by Ghidra decompilation of Ball_ctor2 (0x004039E0)
// Ball_ctor2 initializes these fields to zero:
*(undefined4 *)((int)this + 0x164) = 0; // pos_x
*(undefined4 *)((int)this + 0x168) = 0; // pos_y
*(undefined4 *)((int)this + 0x16c) = 0; // pos_z
*(undefined4 *)((int)this + 0x170) = 0; // vel_x ← **VERIFIED**
*(undefined4 *)((int)this + 0x174) = 0; // vel_y ← **VERIFIED**
*(undefined4 *)((int)this + 0x178) = 0; // vel_z ← **VERIFIED**
Verified by Ball_ApplyForceWithMultipliers (0x00402650)
// Accumulates directional force into velocity:
*(float *)((int)this + 0x170) = param_1 * param_4 + *(float *)((int)this + 0x170);
*(float *)((int)this + 0x174) = param_2 * param_4 + *(float *)((int)this + 0x174);
*(float *)((int)this + 0x178) = param_3 * param_4 + *(float *)((int)this + 0x178);
Verified by Ball_ApplyForceV2 (0x004016F0)
Same pattern — writes to +0x170/174/178 with identical guards.
Layout check (no gaps, no overlaps)
0x164 float pos_x
0x168 float pos_y
0x16C float pos_z
0x170 float vel_x ← confirmed
0x174 float vel_y ← confirmed
0x178 float vel_z ← confirmed
0x17C float accel_x
0x180 float accel_y
0x184 float accel_z
0x188 float max_speed
✅ The velocity offsets are definitively correct.
Ball-Related Functions
Core Lifecycle
| Function | Address | Purpose |
|---|---|---|
Ball_ctor |
0x0040AFE0 |
Full constructor (calls Ball_ctor2, sets vtable to 0x004CF3A0) |
Ball_ctor2 |
0x004039E0 |
Base init — all fields zeroed / defaulted |
Ball_dtor |
0x004027F0 |
Destructor wrapper (calls Ball_dtor2, then _free) |
Ball_dtor2 |
0x00401CC0 |
Actual destructor — resets matrix, calls GameObject_dtor |
Ball_Update |
0x00405E00 |
Main 23-phase physics tick (param_1 = Ball*) |
Ball_FallUpdate |
0x00408830 |
Death-fall physics (out-of-bounds, water, pits) |
Physics & Movement
| Function | Address | What It Does |
|---|---|---|
Ball_ApplyForceWithMultipliers |
0x00402650 |
Add force to velocity with impact/speed/ice/dizzy multipliers |
Ball_ApplyForceV2 |
0x004016F0 |
Alt force app with gravity-plane awareness |
CollisionMesh_SetSpeed |
0x004029C0 |
DEAD CODE. Writes +0xC64 (roll_friction) and +0xC98/C9C/CA0 (unused), but physics loop immediately overwrites them. Does NOT control ball speed. |
Ball_SetVec3AtOffset |
0x00402A20 |
Write Vec3 to arbitrary offset (modding helper) |
Ball_SetTargetPos |
0x00402030 |
Network sync position (lerp toward target) |
Ball_SetTrajectory |
0x00403850 |
Launch-pad trajectory setup (stores params at +0x2AC to +0x2B8) |
Ball_ApplyTrajectory |
0x00403750 |
Execute launch — applies physics_body trajectory, plays sound, sets impact block |
Ball_SetTiltedGravity |
0x00403100 |
Set gravity plane to tilted (normal 0, -1, 0) |
Ball_SetFlatGravity |
0x00403150 |
Set gravity plane to flat (normal 0, 0, 1) |
Ball_CheckProximity |
0x00402150 |
Distance check — sets +0x744 if within threshold |
Ball_FindMeshCollision |
0x00403980 |
Raycast vs level mesh (delegates to Mesh_FindClosestCollision) |
Ball_FindClosestRespawnPoint |
0x00405190 |
Full respawn logic — reset collision, free display string, find nearest checkpoint |
Ball_TestPlaneIntersection |
0x00402810 |
Frustum culling helper for render shadow |
Ball_AdvancePositionOrCollision |
0x004564C0 |
Multiplayer sync position advance |
State Changes
| Function | Address | What It Does |
|---|---|---|
Ball_Shrink |
0x00402200 |
odd race E:SHRINK: radius=13.0, max_speed=2.5, play shrink sound |
Ball_Grow |
0x00402270 |
odd race E:GROW: radius=26.0, max_speed=5.0 (restores from shrunk) |
Ball_ResetCollisionMesh |
0x004030B0 |
Reset physics body orientation, zero velocity, reset timer |
Ball_Shatter |
0x00408D70 |
Arena: split ball into 3 AI balls (called from FollowBall_Update, NOT E:JUMP) |
Ball_DizzyImmunity |
0x00402400 |
Grant dizzy immunity/score at +0x2F4 |
Rendering
| Function | Address | What It Does |
|---|---|---|
Ball_Render |
0x00402DE0 |
Full render pass — shadow, particles, sprite quad |
Ball_RenderShadow |
0x00401920 |
Render ground shadow decal |
Ball_SetupCollisionRender |
0x004015B0 |
Setup collision sounds, render visibility flags |
Ball_InitRenderState |
0x00402860 |
One-time D3D render state init (cull mode, texture stages) |
Ball_RenderWithMaterial |
0x0045D8F0 |
Render with custom material override |
Ball_GetTransform |
0x0040AF90 |
Extract transform matrix for rendering |
Input
| Function | Address | What It Does |
|---|---|---|
Ball_GetInputForce |
0x0046EC30 |
Read input device, output 2D force vector (keyboard DIK codes at InputDevice+0x50C/510/514/518) |
Misc
| Function | Address | What It Does |
|---|---|---|
Ball_SetName |
0x00401660 |
Allocate & copy display name string to +0xC28 |
Ball_CreateTrailParticles |
0x00401DD0 |
Spawn sparkle trail effect at ball position |
What You Can Do With the Ball Object
1. Read/Write Velocity (Instant Boosts)
// Direct velocity manipulation — bypasses all engine multipliers
float* ball = (float*)ball_ptr;
ball[0x170/4] = 5000.0f; // X velocity
ball[0x174/4] = 0.0f; // Y velocity
ball[0x178/4] = 0.0f; // Z velocity
Verified source: Ball_ApplyForceWithMultipliers @ 0x00402650 reads/writes these exact offsets.
2. Read/Write Position (Teleport)
ball[0x164/4] = 100.0f; // X position
ball[0x168/4] = 50.0f; // Y position
ball[0x16c/4] = 200.0f; // Z position
Note: Also set teleport field at +0xC3C to prevent physics from overriding it on the next frame.
3. Change Ball Radius
// Ball_Shrink sets radius = 13.0f (0x41500000)
// Ball_Grow sets radius = 26.0f (0x41D00000)
// Normal radius = 27.0f (0x41D80000) — set in Ball_ctor2
*(float*)(ball + 0x284) = 50.0f; // Giant ball
4. Change Max Speed
// Ball_ctor2 default: 0x459C4000 (~5000.0f)
// Ball_Shrink: 0x40200000 (2.5f)
// Ball_Grow: 0x40A00000 (5.0f)
*(float*)(ball + 0x188) = 10000.0f; // Super speed
5. Force Death-Fall State
Ball_Shrink(ball_ptr); // @ 0x00402200
// Sets: +0xC4C = 1 (is_shrunk), radius=13.0, max_speed=2.5, plays sound
6. Force Split Power-Up
Ball_Shatter(ball_ptr, some_param); // @ 0x00408D70
// Guard: checks *(char*)(ball + 0x324) == 0 (not already split)
7. Apply Launch Pad Boost
// First set trajectory vector on physics body:
*(float*)(*(int*)(ball + 0x1A4) + 0xCA4) = dir_x;
*(float*)(*(int*)(ball + 0x1A4) + 0xCA8) = dir_y;
*(float*)(*(int*)(ball + 0x1A4) + 0xCAC) = dir_z;
Ball_ApplyTrajectory(ball); // @ 0x00403750
// Damps Y by 0.7x, normalizes, scales by 0.01, sets impact block = 100 frames
8. Change Gravity Plane
Ball_SetTiltedGravity(ball); // Normal = (0, -1, 0) @ 0x00403100
Ball_SetFlatGravity(ball); // Normal = (0, 0, 1) @ 0x00403150
Ball_ResetCollisionMesh(ball); // Reset orientation @ 0x004030B0
9. Check If Ball Is Falling
char is_shrunk = *(char*)(ball + 0xC4C);
// Set by Ball_Shrink (→1), cleared by Ball_Grow (→0)
10. Read Ball Speed Scale
float speed_scale = *(float*)(ball + 0x18C); // default 1.0f
// Affects all velocity calculations in Ball_Update
11. Read Player Index
int player_idx = *(int*)(ball + 0x18);
// -1 = no player (AI / demo), 0-3 = human player index
Modding Recipes
Recipe 1: Permanent Super Speed
Hook Ball_ctor2 (0x004039E0), after the line:
*(undefined4 *)((int)this + 0x188) = 0x459c4000; // max_speed = 5000
Change 0x459c4000 to 0x461C4000 (10000.0f).
Recipe 2: Giant Ball Mode
In your mod DLL, every frame after Ball_Update returns:
*(float*)(ball + 0x284) = 100.0f; // radius
The collision mesh (at ball + 0x1A4) must also be scaled — call CollisionMesh_SetRadius or patch the mesh directly.
Recipe 3: Zero-Gravity Mode
Patch Ball_Update to skip gravity accumulation. The gravity vector is at +0x1A8 (default 0, 1.0, 0).
*(float*)(ball + 0x1A8) = 0.0f; // gravity_x
*(float*)(ball + 0x1AC) = 0.0f; // gravity_y
*(float*)(ball + 0x1B0) = 0.0f; // gravity_z
Recipe 4: Invincibility (No Death-Fall)
Hook Ball_Shrink (0x00402200) to return immediately:
xor eax, eax
retn 4
Recipe 5: Always-Split Power-Up
Hook the guard check in Ball_Shatter (0x00408D70):
; NOP out the "if (*(char*)(this+0x324) == 0)" check
nop
nop
nop
nop
nop
nop
Verified Memory Layout (Key Fields)
Extracted directly from Ball_ctor2 decompilation @ 0x004039E0:
| Offset | Type | Initial Value | Description |
|---|---|---|---|
+0x00 |
vtable | 0x004CF314 |
GameObject vtable (ctor2 sets base vtable) |
+0x04 |
byte[2] | 0, 0 |
collision_result flags |
+0x0C |
int | 200 |
string_timer (ms) |
+0x10 |
void* | param_1+0x878 |
App pointer (scene's app ref) |
+0x14 |
void* | param_1 |
Scene pointer |
+0x18 |
int | -1 |
player_index (-1 = none, 0-3 = player) |
+0x1C |
UITimer | — | Timer object (size 0xEC) |
+0x108 |
Timer | — | Main timer |
+0x150 |
int | 0 |
accumulated_time |
+0x154 |
int | RNG | random seed |
+0x158 |
Vec3 | 0,0,0 |
prev_pos |
+0x164 |
float | 0 |
pos_x |
+0x168 |
float | 0 |
pos_y |
+0x16C |
float | 0 |
pos_z |
+0x170 |
float | 0 |
vel_x ← verified |
+0x174 |
float | 0 |
vel_y ← verified |
+0x178 |
float | 0 |
vel_z ← verified |
+0x17C |
float | 0 |
accel_x |
+0x180 |
float | 0 |
accel_y |
+0x184 |
float | 0 |
accel_z |
+0x188 |
float | 0x459C4000 |
max_speed (~5000.0f) |
+0x18C |
float | 0x3F800000 |
speed_scale (1.0f) |
+0x190 |
float | — | facing_angle (computed) |
+0x194 |
float | 0xBF800000 |
(-1.0f) |
+0x198 |
float | — | spin_angle |
+0x1A0 |
float | 0x3F800000 |
(1.0f) |
+0x1A4 |
void* | new CollisionMesh | Physics body pointer |
+0x1A8 |
Vec3 | 0, 1.0, 0 |
gravity vector |
+0x1B8 |
RenderContext | — | Primary render context |
+0x208 |
RenderContext | — | Secondary render context |
+0x260 |
byte | 0 |
boost_active |
+0x264 |
ArenaBoard | — | Rumble timer (0x14 bytes) |
+0x278 |
float | 0x3DCCCCCD |
(0.1f) |
+0x27C |
int | 0 |
— |
+0x281 |
byte | 1 |
unused_init_flag (DEAD: set by ctor, never read by any function) |
+0x284 |
float | 0x41D80000 |
radius (27.0f) |
+0x290 |
int | 0 |
spin_timer |
+0x2A4 |
float | 0x40A00000 |
(5.0f) |
+0x2A8 |
Vec3 | 0,0,0 |
accel vector |
+0x2B8 |
Vec3 | 0,0,0 |
another vector (trajectory?) |
+0x2C0 |
Vec3 | 0,0,0 |
checkpoint position |
+0x2CC |
byte | 0 |
block_input |
+0x2D4 |
byte | 0 |
— |
+0x2D5 |
byte | 0 |
— |
+0x2D8 |
int | 0 |
— |
+0x2DC |
int | 0 |
checkpoint index |
+0x2E8 |
byte | 0 |
event_flag |
+0x2E9 |
byte | 0 |
⚠ impact_shatter (NOT on_ramp! Sticky limit/trajectory, never cleared in Ball_Update) |
+0x2EC |
int | 0 |
current_score |
+0x2F0 |
int | 0 |
impact_counter (frames force is blocked) |
+0x2F4 |
int | 0 |
best_score |
+0x2F8 |
byte | 1 |
alive_flag (set 0 on death) |
+0x2F9 |
byte | 0 |
disabled_flag |
+0x300 |
int | 0 |
— |
+0x310 |
byte | 1 |
can_collide |
+0x314 |
int | 0 |
— |
+0x318 |
int | 0 |
— |
+0x31C |
byte | 0 |
— |
+0x324 |
byte | 0 |
is_split (split power-up active) |
+0x328 |
int | -1 |
— |
+0x32C |
AthenaList | — | Linked list #1 |
+0x744 |
int | 0 |
proximity_counter |
+0x748 |
int | 0 |
gravity_plane_type (0=tilted, 1=flat, 2=?) |
+0x764 |
float | 1.0f |
— |
+0x768 |
byte | 1 |
visible |
+0x769 |
byte | 0 |
— |
+0x76A |
byte | 0 |
has_target_pos (network sync) |
+0x76C |
Vec3 | 0,0,0 |
target_pos (network) |
+0x808 |
int | 0 |
freeze_timer |
+0xC28 |
char* | 0 |
display_name string |
+0xC3C |
byte | 0 |
teleport_flag |
+0xC4C |
byte | 0 |
is_shrunk (runtime) |
+0xC50 |
float | 0 |
fall_depth |
+0xC54 |
int | 0 |
sound_channel |
+0xC58 |
byte | 0 |
— |
+0xC5C |
int | 0 |
dizzy_flag |
+0xC60 |
float | 1.0f |
scale_factor |
+0xC64 |
float | 0 |
roll_friction (overwritten every frame by physics loop) |
+0xC74 |
int | 0 |
render_alpha |
+0xC80 |
byte | 0 |
has_viewport_clip |
+0xC88 |
int[4] | — | viewport clip rect |
+0xC98 |
— | — | End of struct |
How to Use This Document
- Attach a debugger (Cheat Engine, x64dbg) to
Hamsterball.exe. - Find the Ball pointer: In
Ball_Update(0x00405E00), the first argument in ECX isthis. - Read/write fields using the offsets above.
- Hook functions by patching the vtable at
0x004CF3A0or placing JMP hooks at the function addresses.
Sources
All data verified via live GhidraMCP headless decompilation (http://127.0.0.1:8089/decompile_function) on Hamsterball.exe:
Ball_ctor2@0x004039E0— struct layout, initial valuesBall_ApplyForceWithMultipliers@0x00402650— velocity write patternBall_ApplyForceV2@0x004016F0— velocity write pattern (alt)Ball_Shrink@0x002200— odd race shrink state (E:SHRINK)Ball_Grow@0x00402270— odd race grow recovery (E:GROW)Ball_Update@0x00405E00— main physics tick overviewBall_Shatter@0x00408D70— arena 8-ball split mechanic (called from FollowBall_Update)Ball_GetInputForce@0x0046EC30— input system integrationBall_SetTrajectory/Ball_ApplyTrajectory— launch pad mechanics
Document generated: 2026-06-05
Method: Ghidra decompilation + cross-reference with ball_struct.h
Confidence: High — all offsets verified against primary source code
🔗 Related Documents
[[39687043557968|audio system]]
Audio System — Deep Documentation
Architecture Overview
Hamsterball uses a dual-API audio system:
- BASS library (un4seen.com) — music playback (.mo3/.xm/.it tracker modules)
- DirectSound8 — sound effects with 3D positional audio
Both systems are managed through the SoundDevice object stored at App+0x178 (offset 0x84C from SoundDevice's App pointer).
SoundDevice Structure
The SoundDevice (vtable at 0x4D911C) is the central audio manager:
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x000 | 4 | vtable* | SoundDevice vtable |
| +0x004 | 4 | App* | Back-pointer to App |
| +0x008 | 4 | int | Sound entry list count (AthenaList) |
| +0x00C | 4 | int | Sound entry list capacity |
| +0x010 | 4 | int | Next channel index (circular allocator) |
| +0x414 | 4 | int** | Sound entry array pointer |
| +0x838 | 4 | float | Global sound volume (default 1.0, registry "Sound Volume") |
| +0x83C | 1 | bool | Sound enabled flag |
| +0x84C | 4 | IDirectSound8* | DirectSound8 COM interface |
| +0x850 | 4 | int | Listener count |
| +0x854 | varies | Vec3[] | Listener position array (12 bytes each: X, Y, Z) |
| +0x914 | 4 | float | Min rolloff distance |
| +0x918 | 4 | float | Max rolloff distance |
| +0x107*4 | — | AthenaList | Secondary sound buffer list |
Sound Loading Pipeline
Entry Point: Sound_LoadOggOrWav (0x459660)
Sound_LoadOggOrWav(filename):
1. Try "%s.ogg" — check file access
2. If .ogg exists → Sound_LoadOgg()
3. Else try "%s.wav" — check file access
4. If .wav exists → SoundList_LoadWAV()
The game prefers OGG Vorbis over WAV, always trying .ogg first.
SoundList_LoadWAV (0x458F40)
Manual WAV file parser — does NOT use DirectSound helpers:
- Open file, read entire contents into memory
- Validate "RIFF" + "WAVE" headers (4-char string compare)
- Parse WAV format chunks: sample rate, channels, bits per sample
- Scan for "data" chunk by iterating through file
- Create IDirectSound8 buffer (WAVEFORMATEX + DSBUFFERDESC):
DSBUFFERDESC.dwSize = 0x24DSBUFFERDESC.dwFlags = 0x82(DSBCAPS_STATIC | DSBCAPS_CTRLVOLUME)DSBUFFERDESC.dwBufferBytes = data_chunk_size
- Lock buffer (DSLock), copy PCM data, unlock
- Create SoundEntry (12 bytes: vtable + App* + buffer_handle) per DirectSound buffer
- Append to SoundList's AthenaList
- For multi-buffer sounds (streaming): create additional buffers with duplicate entries
Sound_LoadOgg (0x459310)
- Open .ogg file with CRT_fsopen("rb")
- Parse OGG Vorbis headers (channels, sample rate, bits)
- Compute buffer size: channels × bits/8 × sample_count
- Create IDirectSound8 buffer with computed format
- Decode OGG stream into DirectSound buffer (read loop)
- Create SoundEntry entries, append to AthenaList
- For multi-buffer sounds: duplicate entries for streaming segments
SoundEntry Structure (12 bytes)
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x000 | 4 | vtable* | SoundEntry vtable (scalar dtor at 0x4D8E78) |
| +0x004 | 4 | App* | Back-pointer to App |
| +0x008 | 4 | handle | DirectSound buffer handle |
Sound Playback Pipeline
Channel Management
Sound_GetNextChannel (0x459810) — Circular allocator:
Sound_GetNextChannel(SoundList):
index = list.next_index
if index < list.count:
entry = list.entries[index]
list.next_index = index + 1
if entry != NULL: return entry
// Wrap around
list.next_index = 0
if list.count > 0:
return list.entries[0]
return 0
3D Positional Audio
Sound_CalculateDistanceAttenuation (0x466750):
Sound_CalculateDistanceAttenuation(this, x, y, z) → float (0.0-1.0):
// Find nearest listener
best_dist = FLT_MAX
for each listener in this.listeners:
dist = sqrt((x-lx)² + (y-ly)² + (z-lz)²)
if dist < best_dist: best_dist = dist
// Linear rolloff between min and max distances
if best_dist <= this.min_rolloff:
return 1.0 // Full volume
elif best_dist >= this.max_rolloff:
return 0.0 // Silent
else:
range = max_rolloff - min_rolloff
if range == 0.0: range = 1.0 // Avoid divide-by-zero
return 1.0 - (best_dist - min_rolloff) / range
Key constants:
_DAT_004d9120= FLT_MAX (initial distance for comparison)_DAT_004cf368= 0.0 (zero/silence)_DAT_004cf310= 1.0 (full volume)
Sound_Play3D (0x459860):
Sound_Play3D(this, x, y, z):
Sound_CalculateDistanceAttenuation(this->device, x, y, z)
Sound_PlayChannel(this)
Sound_PlayChannel (0x4597B0):
Sound_PlayChannel(SoundList):
if !app || !device || volume == 0.0: return
// Get next channel from circular list
channel = next_entry_from_list
if channel: Sound_StartSample(channel)
else: wrap to beginning, try first entry
Sound_StartSample (0x4595B0):
Sound_StartSample(entry):
if entry->app->sound_enabled:
Reset sample via vtable(+0x48) // IDirectSoundBuffer::SetCurrentPosition(0)
Set volume via vtable(+0x3C) // IDirectSoundBuffer::SetVolume(clamped)
Play via vtable(+0x30, 0, 0, 0) // IDirectSoundBuffer::Play(0, 0, 0)
Sound_InitChannels (0x434580)
Used during game object initialization (e.g. sawblades):
Sound_InitChannels(this, randomize_pitch):
this.sound_flag = 0
this.channel_1 = Sound_GetNextChannel(this->app->sound_ids_0x4AC)
this.channel_2 = Sound_GetNextChannel(this->app->sound_ids_0x4B0)
if channel_1: Sound_StartSample(channel_1)
this.pitch = 0x140 (320 decimal = base pitch/frequency)
if channel_2: Sound_Play3DAtPosition(channel_2)
if channel_1: Sound_Play3DAtPosition(channel_1)
if randomize_pitch:
this.float_pitch = RNG_Rand(0, 25) // Random 0-25
this.sound_playing = 0
Music System (BASS)
MusicPlayer Structure
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x000 | 4 | vtable* | MusicPlayer vtable |
| +0x004 | varies | — | Internal state |
| +0x008 | 4 | HMUSIC | BASS music handle |
| +0x424 | varies | char[] | Path buffer (filepath copy) |
LoadMusicFile (0x46A020)
LoadMusicFile(this, filepath):
strcpy(this+0x424, filepath)
handle = BASS_MusicLoad(0, filepath, 0, 0, BASS_MUSIC_PRESCAN, 0)
this->handle = handle
if handle != 0:
return (1 << 24) | handle // Success
else:
BASS_ErrorGetCode()
Window_Notify("Music Initialization Error: %s")
return 0
BASS_MusicLoad flags: 4 = BASS_MUSIC_PRESCAN (pre-scan for accurate length/sync)
LoadJukebox (0x46A4D0)
Parses jukebox.xml for song definitions:
LoadJukebox(filename):
FileHandle_Open(filename)
while tag = MWParser_ReadTag():
if tag.name == "SONG":
name = ""
hex_id = 0
while inner_tag = MWParser_ReadTag():
if inner_tag.name == "NAME": name = inner_tag.value
if inner_tag.name == "HEX": hex_id = sscanf(value, "%x")
RegKeyList_AppendStr(name, hex_id)
Example jukebox.xml:
<SONG>
<NAME>music_mo3</NAME>
<HEX>1A</HEX>
</SONG>
Audio_StopChannel (0x46A0D0)
Audio_StopChannel(music_player):
BASS_ChannelStop(music_player->handle)
Music Channels (App Offsets)
| Offset | Description |
|---|---|
| App+0x534 | MusicPlayer* (BASS music handle + path at +0x424) |
| App+0x538 | music_channel_2 (BASS channel handle) |
| App+0x53C | music_channel_1 (BASS channel handle) |
Sound Volume Management
Level_ReadSoundVolume (0x466570)
Level_ReadSoundVolume(SoundDevice):
RegKey_Open(app->registry)
if RegKey_ReadString("Sound Volume") exists:
volume = Registry_ReadFloat("Sound Volume")
device->volume = volume
else:
device->volume = 1.0 // 0x3F800000
RegKey_Close()
SoundDevice_dtor (0x4668A0)
On destruction:
- Save volume to registry:
RegKey_WriteDWORD("Sound Volume", device->volume) - Iterate and delete all SoundEntry objects (AthenaList at +0x4)
- Iterate and free secondary buffer list (AthenaList at +0x41C)
- Release IDirectSound8 interface (COM Release at vtable+8)
- Free listener position arrays (Vec3List_Free)
3D Listener System
The 3D listener array enables per-ball positional audio in multiplayer:
- Listener count at
SoundDevice+0x850 - Each listener is a
Vec3(12 bytes: X, Y, Z) starting atSoundDevice+0x854 - Updated per-ball in
Ball_UpdatePhase 17 (multiplayer sync) - Ball positions are the listener positions for distance calculations
Sound Events During Gameplay
Ball_Update Phases
| Phase | Sound Action |
|---|---|
| 0 | Play ambient sound if ambient_sound_id != 0 |
| 12 | Ball-ball collision: Sound_Play3D with collision sound |
| 15 | Calculate distance attenuation; replay ambient if active |
Collision Event Sounds
| Event | Location | Description |
|---|---|---|
| E:JUMP | DispatchCollisionEvents (0x40C5D0) | Jump 3D sound at collision point |
| N:TARPIT | DispatchCollisionEvents | Tarpit 3D sound + physics modify |
| N:MOUSETRAP | DispatchCollisionEvents | Mousetrap 3D sound |
| PIPEBONK | DispatchCollisionEvents | Random from 3 impact sounds |
| E:CATAPULTBOTTOM | TowerCollisionEvents (0x40DCD0) | Catapult launch sound |
| E:OPENSESAME | TowerCollisionEvents | Door opening sound |
| N:TRAPDOOR | TowerCollisionEvents | 3D trapdoor + timer activation |
| E:MACETRIGGER | TowerCollisionEvents | Mace activation 3D sound |
Key Address Map
| Address | Function | Description |
|---|---|---|
| 0x458F40 | SoundList_LoadWAV | Parse WAV → IDirectSound8 buffer |
| 0x459310 | Sound_LoadOgg | Decode OGG Vorbis → IDirectSound8 buffer |
| 0x4595B0 | Sound_StartSample | Start IDirectSound8 sample playback |
| 0x459660 | Sound_LoadOggOrWav | Try .ogg first, fallback .wav |
| 0x4597B0 | Sound_PlayChannel | Dispatch sound to channel pool |
| 0x459810 | Sound_GetNextChannel | Circular channel allocator |
| 0x459860 | Sound_Play3D | 3D positioned sound (attenuation + play) |
| 0x434580 | Sound_InitChannels | Init game object sound channels |
| 0x466570 | Level_ReadSoundVolume | Read volume from registry |
| 0x466750 | Sound_CalculateDistanceAttenuation | Linear 3D rolloff |
| 0x4668A0 | SoundDevice_dtor | Release buffers, save volume |
| 0x46A020 | LoadMusicFile | BASS_MusicLoad wrapper |
| 0x46A0D0 | Audio_StopChannel | BASS_ChannelStop |
| 0x46A4D0 | LoadJukebox | Parse jukebox.xml song list |
Reimplementation Notes (SDL2/SDL_mixer or OpenAL)
Replacement Strategy
- BASS → SDL_mixer: Load .mo3/.xm/.it via SDL_mixer with mikmod/timidity
- DirectSound8 → SDL_mixer: Use SDL_mixer channels (Mix_Chunk/Mix_Music)
- Alternative: OpenAL for true 3D positional audio with proper distance model
Key Differences
- Channel allocation: SDL_mixer has built-in channel management (Mix_GroupChannel), similar to circular allocator pattern
- 3D audio: SDL_mixer lacks native 3D — approximate with pan/volume or use OpenAL
- OGG/WAV loading: Both natively supported by SDL_mixer (Mix_LoadWAV_RW)
- Volume control: SDL_mixer uses 0-128 scale; DirectSound uses dB attenuation
- Music format: .mo3 tracker modules need mikmod/timidity — SDL_mixer supports these natively
Recommended Architecture
// Audio system (SDL_mixer + OpenAL for 3D)
class SoundDevice {
std::vector<SoundEntry> entries;
float volume; // 0.0-1.0
float min_rolloff; // 3D min distance
float max_rolloff; // 3D max distance
std::vector<Vec3> listeners; // Per-ball positions
SoundEntry* LoadOggOrWav(const char* filename);
void Play3D(SoundEntry* entry, Vec3 position);
float CalculateDistanceAttenuation(Vec3 position);
};
class MusicPlayer {
Mix_Music* handle;
std::string filepath;
uint32_t LoadMusicFile(const char* filepath);
void Stop();
};
🔗 Related Documents
[[40451446322523|half_size_all]]
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/mods/half_size_all/README.md)
half_size_all
Shrinks the player's ball to half size by inlining Ball_Shrink's physics fields
How It Works
Hooks Scene_SpawnBallsAndObjects at the point where the player ball has just been created and registered. A code cave checks if the ball's player index (ball+0x18) is 0, and if so, writes the same three fields that Ball_Shrink (0x00402200) sets — but without calling the function, so no sound effect plays.
Fields written (identical to Ball_Shrink):
| Ball Offset | Field | Value | Effect |
|---|---|---|---|
+0x284 |
radius | 13.0 (0x41500000) | Half visual + collision size |
+0x188 |
physics_scale | 2.5 (0x40200000) | Half max speed |
+0xC4C |
is_shrunk | 1 | Shrunk physics state |
Only player index 0 is affected. AI balls, split balls, follow balls, and board-init balls remain normal size.
Hook Details
| Hook Point | Address | Original Instruction | Catches |
|---|---|---|---|
| Scene_SpawnBallsAndObjects | 0x0041C8D7 | MOV byte [ESI+0x281], 0 (7 bytes) |
Player balls (loop, gated on index 0) |
The code cave:
CMP dword [ESI+0x18], 0— is this player 0?- If no → skip to step 4
- If yes → write radius=13.0, physics_scale=2.5, unused_init_flag=1 (NOTE: this flag is DEAD code — never read by any function, but we set it anyway to match original behavior)
- Execute original
MOV byte [ESI+0x281], 0 JMPback to 0x0041C8DE
Files
half_size_balls.c— C source codebass.dll— Compiled DLL (PE32 i386)half_size_balls.zip— Packaged zip
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dllinto the game folder - Launch Hamsterball
A Hamsterball_half_size.log file is written next to the EXE showing whether the hook applied.
🔗 Related Documents
[[52144855544476|global variables]] and Data Cons
Hamsterball — Global Variables and Data Constants
What This Document Is
A reverse-engineering reference for modders and researchers listing global variables and data constants found in Hamsterball.exe. These are symbols that live in the .data or .rdata sections of the PE image and are accessed by the game code through DAT_xxxxxx references, named labels, or inline constants.
Why This Matters for Modding
Many game behaviors are controlled not by function logic but by scalar constants in .data:
- Ball physics multipliers (speed, gravity, friction)
- Camera orbit distance and damping
- Damage/touch force values
- RNG range constants
- Vtable pointers used for dynamic dispatch
If you patch these values in memory or replace them at image load time, you can drastically change game behavior without changing a single instruction.
Table of Contents
- Confirmed Named Globals (via Ghidra renames)
- Game Constants
.data/.rdata(0x004C0000–0x00534...) - Vtables and Jumps in the Data Section
- Named Data Constants with Context
- Float Constants (0x004CF000–0x004D0500)
- String Table Addresses in .rdata
- How to Patch Constants at Runtime
- Recommended Modding Recipes
- Address Quick Reference
- Verification Methodology
Confirmed Named Globals
These have been manually renamed in Ghidra and are confirmed by the renames_backup.json (restored at session start).
| Address | Symbol | Type / Meaning | Xrefs |
|---|---|---|---|
0x004FD680 |
g_App |
App* — global singleton pointer to the App struct |
5 |
0x005341CC |
g_renderIndex |
uint32_t — frame/batch render counter |
3 |
0x004D2334 |
s_BACK |
char or char[] — used by the UI text system for the "Back" button / menu logic |
45 |
0x004D9CDC |
MeshWorld_vtable |
void** — pointer to the MeshWorld vtable |
2 |
0x004F7360 |
PTR_OBJ_VTABLE |
void** — generic object vtable base for Obj class (193 xrefs) |
193 |
Floating-Point Constants (0x004CF000–0x004D0500)
The engine keeps many scalar constants in the .data section. They are NOT loaded as immediate values into instructions; they are loaded as memory references (_DAT_004CFxxx). This makes them trivial to patch from external code.
Core Ball Physics Constants
| Address | Approx. Value | Function | Description |
|---|---|---|---|
0x004CF310 |
1.0f |
Ball_GetInputForce / Ball_ApplyForce |
Full-speed backward / max multiplier |
0x004CF368 |
0.0f |
Ball_ApplyForce, Math_Atan2Angle |
Zero constant (force neutral, atan2 fallback) |
0x004CF380 |
~0.1–0.2 |
Ball_ApplyForce |
Hit recovery multiplier — reduces force after ball takes a hit (+0x2F0 != 0) |
0x004CF378 |
~0.5–0.8 |
Ball_ApplyForce |
Flag +0x324 multiplier (e.g., dizzy state) |
0x004CF374 |
~0.5–0.8 |
Ball_ApplyForce |
Flag +0xC5C (momentum-transfer state) multiplier |
0x004CF3E8 |
1.0f |
Ball_ApplyForce |
Angular velocity scale (applied to +0xFC/0x100/0x104) |
0x004CF3F0 |
0.95f |
Ball_AdvancePosition, Ball_vtable[0x14] |
Gravity / damping constant (per-frame friction multiplier) |
0x004CF36C |
~0.5–0.7 |
Ball_ApplyForce |
Flag +0xC4C (e.g., speed boost / power-down) multiplier |
Modding Recipes — Physics
- Super Ball Mode — set
0x004CF380(hit recovery) to1.0f:
The ball no longer loses momentum when hit by hazards. - Ice Mode — set
0x004CF3F0(damping) to1.0f:
Ball never slows down from friction/gravity. Will fly off levels. - Dizzy Immunity — set
0x004CF378to1.0f:
When the+0x324dizzy flag is set, force is not reduced. - Heavy Steel Ball — set
0x004CF3E8to0.1f:
Angular velocity barely changes; ball rolls slowly.
Camera / Orbit Constants
| Address | Approx. Value | Function | Description |
|---|---|---|---|
0x004CF3EC |
~1.0–2.0 |
Scene_SetCamera |
Camera MIN_DIST for orbit clamping |
0x004CFF78 |
700.0f |
Scene_SetCamera |
Camera MAX_DIST (hard cap fallback) |
0x004D03A4 |
~0.01–0.1 |
Scene_SetCamera |
Camera wave SCALE (Wave_Sin input) |
0x004D03A0 |
~0.5–1.0 |
Scene_SetCamera |
Camera wave AMPLITUDE multiplier |
Modding Recipes — Camera
- Far Orbit — change
0x004CFF78from700.0fto1200.0f:
Camera can zoom much farther from the ball. - Wave-Cam Disable — set
0x004D03A4to0.0f:
Camera sine-wave bob disappears — perfectly smooth follow.
Collision / Touch Damage Constants
| Address | Approx. Value | Function | Description |
|---|---|---|---|
0x004CF484 |
~20.0–50.0 |
Ball_Update / 0x405190 |
Collision distance threshold for hazard proximity check |
0x004CF370 |
~1.0–1.5 |
CollisionEvents |
Direction/force multiplier on collision deflection |
0x004CF508 |
~0.5–1.0 |
Ball_vtable[0x10] (AI update) |
AI chase activation distance ratio |
0x004D0434 |
~2.0–10.0 |
Scene_Spawn |
Ball spawn vertical offset from START marker |
Modding Recipes — Collision
- Hazard Immunity — raise
0x004CF484to99999.0f:
Collision check never succeeds → hazards never touch you. - Spawn Offset Fix — set
0x004D0434to0.0f:
Ball spawns exactly atSTART.yinstead of floating above.
RNG / Math Constants
| Address | Approx. Value | Function | Description |
|---|---|---|---|
0x004CF558 |
360.0f |
Ball_vtable[0x10] (AI) |
Angle wrap helper (add 360 to normalize negative deg) |
0x004CF554 |
0.0f |
Ball_vtable[0x10] (AI) |
Angle comparison lower bound |
0x004CF550 |
~300.0–350.0 |
Ball_vtable[0x10] (AI) |
AI flee distance threshold |
0x004CF48C |
~0.05f |
Ball_vtable[0x10] (AI) |
AI tick accumulator per frame |
0x004D0418 |
~114.59 |
Math_Atan2Angle |
180/π — radian→degree constant |
0x004D03A0 |
~1.0 |
Scene_SetCamera |
Camera sine multiplier |
Graphics / Rendering Constants
| Address | Approx. Value | Function | Description |
|---|---|---|---|
0x004CF3C8 |
1.0f |
LoadBinaryMesh / mesh parser |
UV V-inversion constant (1.0 - v) |
0x004CF454 |
~0.01–0.1 |
MeshWorld_Parse |
Material shininess multiplier |
0x004CF41C |
~1.0 |
Graphics_Initialize |
Light range / projection scale factor |
0x004CF308 |
~0.001–0.01 |
Scene_RenderAllObjects |
Depth bias for shadow decal offset |
Movement / Input Constants
| Address | Approx. Value | Function | Description |
|---|---|---|---|
0x004D0250 |
~0.5f |
Ball_GetInputForce |
Forward half-speed input multiplier |
Vtables and Jumps
These are pointers to vtable arrays or function pointer tables stored in .data.
| Address | Symbol | What it points to | Usage |
|---|---|---|---|
0x004D9CDC |
MeshWorld_vtable |
MeshWorld class vtable (2 xrefs) |
Level geometry dispatch |
0x004CE400 |
(implicit) | App vtable base |
App virtual method dispatch (see APP_OBJECT.md) |
0x004CF3A0 |
(implicit) | Ball vtable |
Ball virtual method array (set in Ball_ctor) |
0x004D0260 |
(implicit) | Scene vtable |
Scene virtual method array |
0x004F7360 |
PTR_OBJ_VTABLE |
Generic Obj base-class vtable |
193 xrefs — extremely common base pointer |
String Table Addresses in .rdata
The binary embeds strings at fixed addresses in the .rdata section. Some are used as global identifiers passed to functions.
| Address | String / Content | Used By |
|---|---|---|
0x004D9E54 |
String-table base for strtok-style token parsing in MESHWORLD parser |
MeshWorld_Parse / FUN_004BC0D1 |
0x004D48A0 |
Save-file header / format string | SaveTournament |
0x004D9120 |
999999.0f or similar sentinel float |
Ball_Update (AI min-distance init) |
0x004D2334 |
s_BACK — "Back" button / menu state |
45 xrefs across UI code |
How to Patch Constants at Runtime
Method 1 — External DLL
// In your mod DLL (injected into Hamsterball.exe process)
#define ADDR_HIT_RECOVERY ((float*)0x004CF380)
#define ADDR_DAMPING ((float*)0x004CF3F0)
void __declspec(dllexport) ApplyMods() {
// Make the pages writable
DWORD oldProtect;
VirtualProtect(ADDR_HIT_RECOVERY, sizeof(float), PAGE_READWRITE, &oldProtect);
// Patch values
*ADDR_HIT_RECOVERY = 1.0f; // Never reduce force on hit
*ADDR_DAMPING = 0.999f; // Very slippery ball
VirtualProtect(ADDR_HIT_RECOVERY, sizeof(float), oldProtect, &oldProtect);
}
Method 2 — Cheat Engine
- Open Cheat Engine, attach to
Hamsterball.exe - Add Address Manually → enter
004CF3F0as float - Freeze value at
1.0f→ instant zero-friction mode
Method 3 — DLL Injection at Load Time
Use detours, MinHook, or a manual IAT hook. Patch .data section before WinMain runs so the game never sees the original values.
Recommended Modding Recipes
1. God Mode (Hazard Immunity)
*(float*)0x004CF484 = 99999.0f; // Distance threshold
2. Super Speed
*(float*)0x004CF3F0 = 1.0f; // No friction damping
*(float*)0x004CF310 = 5.0f; // Max backward speed
3. Dizzy Immunity
*(float*)0x004CF378 = 1.0f; // Dizzy flag no longer reduces force
4. Smooth Camera
*(float*)0x004D03A4 = 0.0f; // Wave_Sin scale → no bob
*(float*)0x004CFF78 = 2000.0f; // Far max orbit
5. Spawn Height Fix
*(float*)0x004D0434 = 0.0f; // No offset above START marker
Address Quick Reference
0x004CF308 Depth bias (shadow decal)
0x004CF310 Max movement multiplier (1.0f)
0x004CF368 Zero float (0.0f)
0x004CF36C Speed boost / power-down mult
0x004CF370 Collision deflection dir multiplier
0x004CF374 Momentum-transfer mult
0x004CF378 Dizzy state force mult
0x004CF380 Hit recovery force mult
0x004CF3C8 UV V-inversion (1.0f)
0x004CF3E8 Angular velocity scale (1.0f)
0x004CF3EC Camera MIN_DIST
0x004CF3F0 Gravity / damping (0.95f)
0x004CF41C Light range scale
0x004CF454 Shininess multiplier
0x004CF484 Collision dist threshold
0x004CF48C AI tick accumulator (~0.05)
0x004CF508 AI activation distance ratio
0x004CF550 AI flee distance
0x004CF554 Angle lower bound (0.0)
0x004CF558 Angle wrap (360.0)
0x004CFF78 Camera MAX_DIST (700.0)
0x004D0250 Forward half-speed (~0.5)
0x004D03A0 Camera wave amplitude
0x004D03A4 Camera wave scale
0x004D0418 180/PI rad→deg
0x004D0434 Spawn vertical offset
0x004D2334 s_BACK (UI string)
0x004D48A0 Save header string
0x004D9120 Huge float sentinel
0x004D9CDC MeshWorld_vtable
0x004D9E54 MESHWORLD tokenizer string table
0x004F7360 PTR_OBJ_VTABLE (193 xrefs)
0x004FD680 g_App (global App* pointer)
0x005341CC g_renderIndex
Verification Methodology
All addresses in this document were derived from:
- Ghidra decompilation (
analysis/ghidra/decompilations/) — raw_DAT_004xxxxxreferences in.cfiles - Ghidra
renames_backup.json— manually renamed global labels - GhidraMCP
list_globals— live project query of labeled data - Cross-reference count from Ghidra xrefs — values with ≥3 xrefs are high-confidence
What was NOT verified
- Exact float values at each address (Ghidra shows
_DAT_names but not the literal in decompilation). Where exact values are given, they are inferred from context (e.g.,* 0.95fcomment near0x004CF3F0). To get ground-truth values, read 4 bytes at the VA in the running process. - Values may change between game versions. This document targets the version analyzed (
Hamsterball.exeMD5 fromdocs/APP_OBJECT.md).
Document Revision
- Compiled from: Ghidra project,
renames_backup.json, raw decompilations - Coverage: 24 unique
DAT_004xxxxxreferences extracted from raw.cfiles + 5 named globals - Next additions: Runtime memory dump verification (read actual float/int values at each VA), more vtable addresses, additional string table bases
For object-level offsets (App, Ball, Scene structs), see APP_OBJECT.md, BALL_OBJECT_MODDING.md, and SCENE_STRUCT.md.
🔗 Related Documents
[[53708688069228|8-ball spawn mod]]
8-Ball Spawn Mod
Press B during gameplay to spawn an 8-ball in front of your hamster ball. The 8-ball spawns with physics and rolls/collides just like the balls in Rodent Rumble arenas.
What's New
- Audio forwarding: All 10 BASS functions the game imports are forwarded to
bass_real.dll. The game now has full audio while the mod is active. - Single-ball overwrite: Only one 8-ball can exist at a time. Pressing B again repositions the existing 8-ball to your current location and velocity — no new ball is created.
- Dead AI fields documented: CHASE/HOME/SPINDISTANCE values are set but have no effect because
is_8ball(ball+0xC74) is never set to 1. The ball is a physics-only debris ball.
Installation
- In your Hamsterball game folder, rename
bass.dll→bass_real.dll - Copy the mod's
bass.dllinto the game folder - (Optional) Copy
8ball_spawn.ininext tobass.dllfor configuration - Launch the game, enter a level or arena, press B to spawn an 8-ball
How It Works
The 8Ball mesh is preloaded by the game's resource loader into the board mesh array at board+0x268 (index 9 of the array at board+0x244). The ball mesh index field ball+0x754 controls which mesh the ball uses — setting it to 9 makes the ball render as the 8-ball.
Ball creation follows the same pattern as CreateBadBall (0x40BCA0):
operator_new(0xC98)allocates ball memoryBall_ctor(mem, scene)constructs the ballvtable[1]()initializes physics defaults- Position is set in front of the player's ball
ball+0x754 = 9selects the 8Ball meshball+0x18 = -1sets player_index to none (debris ball)- Player's exact velocity vector is copied (same direction and speed)
- Radius stays at
Ball_InitPhysicsDefaultsdefault (35.0) - Ball is added to
scene+0x29D4(bad_balls_list) andscene+0x2DEC(all_balls_list)
Single-Ball Overwrite
If a previously spawned 8-ball is still valid (vtable check against 0x4CF3A0), pressing B again repositions it to the new spawn location with the player's current velocity. No new allocation is made — the same ball object is reused. If the old ball has been freed or corrupted, a fresh ball is allocated.
Audio Forwarding
The mod loads bass_real.dll on the first BASS function call (not in DllMain, to avoid loader lock deadlocks on real Windows). All 10 functions the game imports are forwarded with correct __stdcall calling conventions:
| Function | Forwarded |
|---|---|
| BASS_Init | ✅ |
| BASS_Free | ✅ |
| BASS_Start | ✅ |
| BASS_Stop | ✅ |
| BASS_SetConfig | ✅ |
| BASS_ErrorGetCode | ✅ |
| BASS_MusicLoad | ✅ |
| BASS_MusicPlayEx | ✅ |
| BASS_ChannelSetAttributes | ✅ |
| BASS_ChannelStop | ✅ |
If bass_real.dll is not found, all functions return success values (1/0) — the game runs without audio but does not crash.
AI Fields (Dead Code)
The mod sets CHASE=100000, HOME=100000, SPINDISTANCE=1.0 on the spawned ball. However, these fields have no effect because:
ball+0xC74(is_8ballflag) is never set to 1- Without that flag,
Ball_Update(0x408390) skips the AI chase block entirely viaif (ball[0x31d] != 0 || scene[0x237] != 0) - The ball behaves as a physics-only debris ball — it rolls, falls, and collides but never actively chases other balls
To enable AI behavior, ball+0xC74 would need to be set to 1. This would make the 8-ball chase the nearest player using the CHASE/HOME/SPINDISTANCE parameters.
Configuration
Edit 8ball_spawn.ini (placed next to bass.dll):
[8ball]
spawn_key=0x42 ; B key (default). See VK_* constants on MSDN
spawn_distance=40 ; Distance in front of player
Build
i686-w64-mingw32-gcc -shared -o bass.dll 8ball_spawn.c \
-lwinmm -Wl,--enable-stdcall-fixup \
-O2 -static -static-libgcc -Wl,--add-stdcall-alias
Files
| File | Description |
|---|---|
8ball_spawn.c |
Mod source code |
8ball_spawn.ini |
Configuration file |
bass.dll |
Compiled mod (proxy bass.dll) |
Technical Notes
- BASS_SetConfig/BASS_GetConfig are now forwarded to bass_real.dll (previously stubbed). The game calls BASS_SetConfig via SEH-protected code — forwarding with correct
__stdcallconvention works safely. - Scene/Board discovery uses brute-force App struct scan for the Scene vtable (0x4D0260), same approach as the player_clones mod.
- The 8-ball has no AI — it spawns as a physics debris ball with player_index = -1. It rolls, falls, and collides with other balls and geometry.
load_real_bass()is called fromspawn_thread(not DllMain) to avoid the Windows loader lock deadlock that affectsLoadLibraryAcalls during DLL initialization.
🔗 Related Documents
[[53892486924051|collision system]] Deep Dive
Collision System Deep Dive
Overview
Hamsterball uses a spatial tree (octree) for broad-phase collision detection,
with AABB (Axis-Aligned Bounding Box) tests and per-triangle collision mesh
processing for narrow-phase. Ball_AdvancePositionOrCollision (0x4564C0) is the
main physics entry point that delegates to collision handlers via vtable dispatch.
Spatial Tree (Octree)
Collision_TraverseSpatialTree (0x465EF0)
Recursive traversal of the octree structure. For each node:
-
Descend children (AthenaList at node+0x18):
- Get first child from node+0x424
- For each child: recurse into Collision_TraverseSpatialTree(child, aabb, result_list)
- Increment iterator at node+0x20
-
Process leaf data (AthenaList at node+0x2C):
- Get collision mesh buffer from node+0x438
- For each collision mesh buffer:
- For each vertex group (3 vertices per face = 1 triangle):
- AABB_ContainsPoint(param_1, vx, vy, vz)
- If contained: AthenaList_Append(param_2, vertex_ptr)
- For each vertex group (3 vertices per face = 1 triangle):
AABB_ContainsPoint
Tests whether a point (x, y, z) is inside an axis-aligned bounding box.
Returns 0 (outside) or 1 (inside).
CollisionMesh Structure (0xCB0 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x00 | vtable* | Mesh_DeletingDtor (0x4D8E10) |
| +0x10 | void* | Scene pointer (param_1) |
| +0x18 | AthenaList | Child mesh list (octree children) |
| +0x430 | AthenaList | Face/vertex data (collision geometry) |
| +0x848 | AthenaList | Additional mesh data |
| +0xC74 | int | Flags/counter |
| +0xCA4 | Vec3 | AABB min (or position) |
| +0xCA8 | Vec3 | AABB max (or size) |
| +0xCAC | int | Collision state flag |
CollisionMesh_ctor (0x456D80)
CollisionMesh_ctor(this, scene):
this->vtable = 0x4D8E10 // Mesh_DeletingDtor
this->scene = scene // +0x10
AthenaList_Init(this + 0x18) // children
AthenaList_Init(this + 0x430) // faces
AthenaList_Init(this + 0x848) // mesh data
this->flags = 0 // +0xC74
this->aabb_min = {0,0,0} // +0xCA4
this->aabb_max = {0,0,0} // +0xCA8
this->state = 0 // +0xCAC
Ball_InitBattleMode(this)
Collision Event Dispatch (Per-Board Handlers → Shared Base)
IMPORTANT: There is no single "Level" or "Arena" handler. Almost every board type overrides vtable[0x1D] (+0x74) with its own unique collision handler. Each processes board-specific events, then falls through to DispatchCollisionEvents (0x40C5D0) as the shared base. Arena (Rumble) boards have their own separate vtables with separate handlers.
Race Board Handlers
| Board | Handler Address | Handler Name |
|---|---|---|
| WarmUp | 0x0040C5D0 | DispatchCollisionEvents (no override — uses base directly) |
| Intermediate | 0x0040D340 | IntermediateCollisionEvents |
| Dizzy | 0x0040D500 | DizzyCollisionEvents |
| Tower | 0x0040DCD0 | TowerCollisionEvents |
| Expert | 0x0040E6A0 | ExpertCollisionEvents |
| Odd | 0x0040ED30 | OddCollisionEvents |
| Wobbly | 0x0040F9A0 | WobblyCollisionEvents |
| Toob | 0x00410020 | ToobCollisionEvents |
| Sky/Neon | 0x00410D00 | NeonCollisionEvents |
| Beginner | 0x004111E0 | BeginnerCollisionEvents |
| Up | 0x004119B0 | UpCollisionEvents |
| Master | 0x00412850 | MasterCollisionEvents |
| Glass | 0x00417EB0 | GlassCollisionEvents |
| Impossible | 0x00418360 | ImpossibleCollisionEvents |
Arena (Rumble) Board Handlers
| Arena | Handler Address | Handler Name | Events |
|---|---|---|---|
| Beginner Arena | 0x00413DF0 | BeginnerArenaCollisionEvents | N:BUMPER, DN:SINKPLATFORM |
| Intermediate Arena | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM only |
| Dizzy Arena | 0x00414350 | DizzyArenaCollisionEvents | N:SWIRL, DN:SINKPLATFORM |
| Tower Arena | 0x00414570 | TowerArenaCollisionEvents | E:CATAPULTBOTTOM, DN:SINKPLATFORM |
| Up Arena | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM only |
| Odd Arena | 0x00414DA0 | OddArenaCollisionEvents | E:GRAVITY, DN:SINKPLATFORM |
| Expert Arena | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM only |
| Toob Arena | 0x00415010 | ToobArenaCollisionEvents | N:BUMPER, DN:SINKPLATFORM |
| Wobbly Arena | 0x00415540 | WobblyArenaCollisionEvents | N:SQUAREWOBBLY, DN:SINKPLATFORM |
| Sky/Neon Arena | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM only |
| Warmup Arena | 0x00416140 | WarmupArenaCollisionEvents | E:LAUNCH, DN:SINKPLATFORM |
| Impossible Arena | 0x00418600 | ImpossibleArenaCollisionEvents | N:BOUNCE, DN:SINKPLATFORM |
| Master Arena | 0x00412850 | MasterCollisionEvents | (shared with race board) |
Board vtable[0x1D] (board-specific handler)
├─ Process board-specific events (if event name matches)
│ └─ Return early (for some events) OR fall through
└─ DispatchCollisionEvents (0x40C5D0) — shared base handler
└─ Process universal events (N:GOAL, N:TARPIT, N:WATER, E:JUMP, etc.)
Tower Events (TowerCollisionEvents)
| Event Name | Description |
|---|---|
| E:CATAPULTBOTTOM | Launch pad activation |
| N:TRAPDOOR | Trapdoor opening |
| E:OPENSESAM | Open trapdoor |
| E:BITE | Mace/Chomper bite damage |
| E:MACETRIGGER | Trigger mace swing |
| N:MACE | Mace ball bounce |
Expert Events (ExpertCollisionEvents)
| Event Name | Description |
|---|---|
| E:CALLHAMMER | Hammer chase activation |
| E:HAMMERCHASE | Hammer movement |
| E:ALERTSAW1/2 | Saw warning |
| E:ACTIVATESAW1/2 | Saw blade activation |
| E:ALERTJUDGES | Reset all judges |
| E:SCORE | Score display |
| E:BELL | Bell + bonus time |
Base Events (DispatchCollisionEvents)
| Event Name | Description |
|---|---|
| SECRET | Secret area discovered |
| UNLOCKSECRET | Secret unlock reward |
| NODIZZY | No-dizzy powerup |
Event strings can include <TIME> XML tags for timed events.
Ball Physics Pipeline
Ball_AdvancePositionOrCollision (0x4564C0)
6-phase physics pipeline:
- Free Lists: Release trail point and collision marker lists
- Input Velocity: Add input force to velocity, clamp to max_speed
- Damping: Apply damping formula:
(1-dt) + (1-damping)*dt - Collision: If collision flag set → vtable[0x1C] dispatch
- Gravity: Apply gravity with 0.95 damping constant
- Trail Recording: Record trail points on non-collision frames
Ball Collision Constants
| Constant | Value | Description |
|---|---|---|
| _DAT_004CF310 | 1.0 | Forward speed |
| _DAT_004CF368 | 0.0 | Zero |
| _DAT_004CF3F0 | 0.95 | Damping constant |
| _DAT_004D03A0 | Wave scale | Oscillation scale for path camera |
| _DAT_004D03A4 | Wave frequency | Oscillation frequency |
| _DAT_004CFF78 | 700.0 | Path camera max distance |
| _DAT_004CF3EC | min_dist | Path camera minimum distance |
Ball Struct Physics Offsets
| Offset | Type | Description |
|---|---|---|
| +0x14 | char | Trail recording flag |
| +0x18 | AthenaList | Trail points (Material structs, 0x68 each) |
| +0x164 | Vec3 | Position (X) |
| +0x170 | Vec3 | Velocity |
| +0x188 | float | Max speed (5000.0 = 5.0f) |
| +0x18C | float | Speed scale |
| +0x1A4 | void* | Physics body |
| +0x1A8 | float | Gravity scale |
| +0x27C | float | Radius factor |
| +0x284 | float | Radius (26.0 default) |
| +0x744 | int | Shake flag (camera shake) |
| +0x758 | Vec3 | Camera target |
| +0x76C | Vec3 | Camera actual |
| +0x848 | AthenaList | Collision markers |
| +0xC604 | vtable* | Collision handler (vtable[0x1C]) |
| +0xCA4 | Vec3 | Velocity X/Y/Z |
| +0xC64 | float | Speed scale factor |
| +0xC68 | float | Damping factor |
| +0xC70 | float | Max velocity clamp |
| +0xC74 | float | Accumulated trail distance |
| +0xC7C | char | Collision occurred flag |
| +0xC88 | Vec3 | Gravity components |
| +0xC94 | float | Gravity scale (0.5 default) |
Face/Vertex Data Format
CollisionFace Structure (0x18 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x00 | vtable* | Face vtable |
| +0x04 | uint16 | Vertex index A |
| +0x08 | uint16 | Vertex index B |
| +0x0C | uint16 | Vertex index C |
| +0x10 | uint16 | Face type (0=solid, 1=trigger, etc.) |
| +0x14 | float | Distance/direction |
MeshBuffer Structure (0x14 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x00 | uint32 | Vertex count |
| +0x04 | uint32 | Vertex offset |
| +0x08 | uint32 | Index count |
| +0x0C | uint32 | Index offset |
| +0x10 | uint32 | Face count |
.COL Binary Format
Header: "COL\0" (4 bytes)
uint32 vertex_count
uint32 face_count
uint16 vertex_coords[vertex_count * 3] // 16-bit fixed-point
float positions[vertex_count * 3] // 32-bit float positions
Key Collision Functions
| Address | Function | Description |
|---|---|---|
| 0x4564C0 | Ball_AdvancePositionOrCollision | Main physics pipeline (6 phases) |
| 0x465EF0 | Collision_TraverseSpatialTree | Octree traversal + AABB test |
| 0x456D80 | CollisionMesh_ctor | Collision mesh constructor |
| 0x40E6A0 | ExpertCollisionEvents | Expert board collision handler |
| 0x418360 | ImpossibleCollisionEvents | Impossible board collision handler (N:BOUNCE, N:ONROTATOR, N:ONGEAR) |
| 0x40DCD0 | TowerCollisionEvents | Tower board collision handler |
| 0x40C5D0 | DispatchCollisionEvents | Base collision handler |
| 0x46B070 | FlagWaver_Render | Water ripple collision (physics-based) |
| 0x415480 | CreateWobbly1 | Wobbly bridge (baked vertex animation) |
| 0x43B6F0 | Rotator_AddBall | Registers ball on rotator tracking list (formerly misnamed Rotator_AddBall) |
| 0x43E600 | Catapult_Update | Per-frame rotation of tracked balls (applies rotation matrix to pos+vel) |
| 0x43E9C0 | Catapult_AddObjectConditional | Registers ball on catapult/gear tracking list (guarded by +0x1510) |
| 0x434290 | Catapult_Launch | Launch pad activation (sets catapult+0x10F0=1, +0x10F4=50 timer) |
Rotator System (Gears, Swirls, Spinny Objects)
Spinning objects in Hamsterball physically carry the ball using a two-function system:
Rotator_AddBall(0x43B6F0) — called on collision withN:ONROTATOR,N:SPINNY, orN:SWIRLCatapult_Update(0x43E600) — called every frame, applies rotation matrix to tracked balls
How it works
When the ball touches a spinning object's collision surface, the collision handler calls Rotator_AddBall(scene, ball). This function:
- Searches the rotator's AthenaList (at
scene+0x10F0) for the ball pointer - If found: resets the entry's tick counter to 10 (ball already tracked — keeps it on the rotator)
- If not found: allocates an 8-byte struct
[ball_ptr, tick_counter=10]and appends it to the list
Every frame, Catapult_Update iterates the ball-tracking list:
- Decrements each entry's tick counter
- If counter reaches 0: frees the entry (ball released from rotator)
- Otherwise: applies the object's rotation matrix to the ball's position (
ball+0x164/+0x168/+0x16C) and velocity (ball+0xCA4/+0xCA8/+0xCAC)
10-frame grace period (not a carry limit)
The counter resets to 10 every frame the ball remains in contact with the rotator surface, because Ball_FallUpdate fires the collision event every frame. The countdown only starts ticking down after the ball leaves the rotator. This means:
- While on the rotator: ball stays tracked indefinitely (counter keeps resetting to 10)
- After leaving: 10 more frames of rotation before release (smooth transition)
Rotator/Catapult struct offsets
| Offset | Type | Field |
|---|---|---|
| +0x436 | float | centerX (pivot X) |
| +0x437 | float | centerY (pivot Y) |
| +0x438 | float | centerZ (pivot Z) |
| +0x439 | float | rotSpeedZ |
| +0x43A | float | rotSpeedX |
| +0x43B | float | rotSpeedAngle |
| +0x43C | float | rotAngle (accumulated, decremented by rotSpeed each frame) |
| +0x43E | AthenaList | ballList (tracked balls with tick counters) |
| +0x10F0 | AthenaList | rotatorList (Scene-level, used by Rotator_AddBall) |
| +0x10F8 | AthenaList | catapultBallList (used by Catapult_AddObjectConditional) |
| +0x1510 | byte | active flag (Catapult_AddObjectConditional guard) |
Event → collision handler mapping
| Event | Collision Handler | Level |
|---|---|---|
| N:ONROTATOR | ImpossibleCollisionEvents (0x418360) | Impossible race (gears) |
| N:SPINNY | ToobCollisionEvents (0x410020) | Toob race |
| N:SWIRL | DizzyArenaCollisionEvents (0x414350) | Dizzy arena |
| N:ONGEAR | ImpossibleCollisionEvents (0x418360) | Impossible race (calls Catapult_AddObjectConditional) |
🔗 Related Documents
00 - RE Mindset
00 - RE Mindset
Reverse engineering is evidence-based reconstruction.
Core Principles
- The binary is the ground truth. Tools and docs are hypotheses.
- Never trust a name without evidence. Even a pretty function name is just a label.
- Work from the known to the unknown. Start with strings, imports, and entry points; move toward internal logic.
- Cross-reference everything. A struct offset needs at least two decompilations before it is trustworthy.
- Write as you go. A session without notes is a session you will repeat.
When You Are Stuck
- Look for the constructor. It tells you field sizes and default values.
- Look for the destructor. It tells you nested objects and cleanup order.
- Look for the update loop. It tells you which fields actually matter.
- Look for string comparisons. They reveal state machines and object types.
- Look for magic numbers.
0x3F800000is 1.0f;0x40400000is 3.0f.
Reproducibility
Record:
- Binary MD5/SHA256
- Tool versions (Ghidra, plugin, OS)
- Exact commands run
- Failed hypotheses, not just successes
🔗 Related Documents
01 - Bootstrap a Hamsterball R
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/01-BOOTSTRAP.md)
01 - Bootstrap a Hamsterball RE Project
This guide assumes you have access to the hamsterball-re repo and the original game assets.
1. Workspace Layout
hamsterball-re/
├── originals/ # NEVER modify these
│ ├── installer/
│ └── installed/
│ └── extracted/
│ └── Hamsterball.exe
├── analysis/
│ ├── ghidra/
│ │ ├── HamsterballProject/
│ │ ├── scripts/
│ │ ├── structs/
│ │ └── renames_backup.json
│ ├── code_ref/
│ └── screenshots/
├── docs/ # Human-readable findings
├── reimpl/ # Reimplementation source
└── tools/ # Custom RE tooling
2. Hash Everything First
Before analysis, record integrity baselines:
cd ~/hamsterball-re
python3 - <<'PY'
import hashlib, json, os
root = "originals/installed/extracted"
hashes = {}
for dirpath, _, files in os.walk(root):
for f in files:
p = os.path.join(dirpath, f)
with open(p, "rb") as fh:
data = fh.read()
hashes[os.path.relpath(p, root)] = {
"md5": hashlib.md5(data).hexdigest(),
"sha256": hashlib.sha256(data).hexdigest(),
"size": len(data)
}
with open("docs/FILE_HASHES.json", "w") as out:
json.dump(hashes, out, indent=2)
print("hashed", len(hashes), "files")
PY
Known good MD5 for Hamsterball.exe: 7d25019366b8d7f55906325bd630d7fe.
3. Initial Binary Reconnaissance
# File type and PE layout
file Hamsterball.exe
objdump -x Hamsterball.exe | head -80
# String sweeps
strings Hamsterball.exe | grep -iE '\.(dll|mesh|world|xml|wav|png|bmp|mo3)'
strings Hamsterball.exe | grep -iE 'direct3d|d3d|dinput|dsound|bass'
strings Hamsterball.exe | grep -iE 'menu|level|race|score|hamster'
Key findings to expect:
- PE32 i386, Visual C++ 2003 era
- Imports from
D3D8.dll,DINPUT8.dll,DSOUND.dll,BASS.dll - Window class name
AthenaWindowat ~0x4D9374 - Engine assets under
levels\,data\,fonts\
4. PE Analysis With Python
python3 - <<'PY'
import pefile
pe = pefile.PE("originals/installed/extracted/Hamsterball.exe")
print(f"Image base: 0x{pe.OPTIONAL_HEADER.ImageBase:08X}")
print(f"Entry point RVA: 0x{pe.OPTIONAL_HEADER.AddressOfEntryPoint:08X}")
print("Imports:")
for entry in pe.DIRECTORY_ENTRY_IMPORT:
print(f" {entry.dll.decode()}: {len(entry.imports)} imports")
PY
5. Asset Discovery
Common asset directories and files:
levels\ # .MESH and .MESHWORLD level files
data\ # HS.CFG save, RaceData.xml, Jukebox.xml, etc.
fonts\ # .description + .png font data
meshes\ # ball, hamster, hazards
sounds\ # .wav SFX
music\ # .mo3 tracker music (BASS)
6. First Wine Run (Optional)
Never background Wine. Instead:
cd originals/installed/extracted
WINEDEBUG=+loaddll timeout 30 wine Hamsterball.exe 2>&1 | tee /tmp/wine_first_run.log
Look for missing DLLs in the log.
🔗 Related Documents
01 - Targets and Tools
01 - Targets and Tools
Supported Binary Formats
| Format | Extensions | Typical Tools |
|---|---|---|
| PE32/PE32+ | .exe, .dll | pefile, Ghidra, x64dbg |
| ELF | (none), .so | readelf, Ghidra, objdump |
| Mach-O | .app, .dylib | otool, Ghidra, radare2 |
| Raw/Firmware | .bin | binwalk, Ghidra, IDA |
Analysis Types
Static analysis: No code execution. Safe and repeatable.
- File headers, strings, imports/exports, disassembly, decompilation.
Dynamic analysis: Code runs under observation.
- Debugging, hooking, memory dumps, API tracing.
Tool List
| Task | Tool |
|---|---|
| Disassemble/decompile | Ghidra, IDA, radare2, Binary Ninja |
| PE inspection | pefile, Detect It Easy, CFF Explorer |
| Strings | strings |
| Hex/entropy | 010 Editor, ImHex, binwalk |
| Debugger | x64dbg, WinDbg, gdb, LLDB |
| API tracing | API Monitor, Process Monitor, frida |
| Network | Wireshark, tcpdump |
🔗 Related Documents
02 - Binary Reconnaissance
02 - Binary Reconnaissance
Initial Commands (any binary)
file target.exe
strings target.exe | head -50
strings target.exe | grep -iE '\.(dll|so|dylib)'
strings target.exe | grep -E '^[A-Za-z_][A-Za-z0-9_]*$' | sort | uniq -c | sort -rn | head
python3 - <<'PY'
import pefile, hashlib
with open('target.exe','rb') as f: data = f.read()
print('md5', hashlib.md5(data).hexdigest())
print('sha256', hashlib.sha256(data).hexdigest())
pe = pefile.PE('target.exe')
print('imagebase', hex(pe.OPTIONAL_HEADER.ImageBase))
print('entrypoint', hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint))
print('imports')
for entry in pe.DIRECTORY_ENTRY_IMPORT:
print(' ', entry.dll.decode(), len(entry.imports))
PY
What to Look For
- Import table: reveals graphics/audio/input/network libraries.
- String table: filenames, URLs, error messages, debug paths, class names.
- Sections: large
.text,.rdata,.data; unusual names may indicate packing. - Entropy: packed/encrypted sections have entropy near 8.
- Exports: DLL interfaces, COM classes, plugin APIs.
Red Flags
| Sign | Meaning |
|---|---|
High entropy .text |
Packed or encrypted code |
| Missing normal imports | Custom loader or syscall usage |
| TLS callback | Anti-debug / early execution |
| Resources with high entropy | Embedded payload |
🔗 Related Documents
02 - Ghidra Setup and GhidraMC
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/02-GHIDRA-SETUP.md)
02 - Ghidra Setup and GhidraMCP Headless Server
1. Import the Binary
Use Ghidra's headless analyzer (do this once):
export GHIDRA_HOME=/opt/ghidra_12.0.4_PUBLIC
mkdir -p ~/hamsterball-re/analysis/ghidra/HamsterballProject
$GHIDRA_HOME/support/analyzeHeadless \
~/hamsterball-re/analysis/ghidra/HamsterballProject \
Hamsterball \
-import ~/hamsterball-re/originals/installed/extracted/Hamsterball.exe \
-overwrite
Warning: -overwrite wipes any existing renames. Only do this if restoring from FUNCTION_MAP.md afterwards.
2. Install / Update GhidraMCP Extension
The project uses GhidraMCP for REST-based decompilation. Extension path:
~/.config/ghidra/ghidra_12.0.4_PUBLIC/Extensions/GhidraMCP/lib/GhidraMCP-5.12.0.jar
Update procedure:
cd /tmp && mkdir -p ghidra-mcp-update && cd ghidra-mcp-update
curl -sLO https://github.com/benethington/ghidra-mcp/releases/latest/download/GhidraMCP.zip
curl -sLO https://github.com/benethington/ghidra-mcp/releases/latest/download/bridge_mcp_ghidra.py
unzip -q GhidraMCP.zip -d ~/.config/ghidra/ghidra_12.0.4_PUBLIC/Extensions/GhidraMCP
sudo mkdir -p /opt/ghidra-mcp
sudo cp bridge_mcp_ghidra.py /opt/ghidra-mcp/
3. Start the Headless Server
Use a Hermes background terminal process (do NOT use nohup ... & in foreground):
export GHIDRA_HOME=/opt/ghidra_12.0.4_PUBLIC
MCP_JAR=/home/evan/.config/ghidra/ghidra_12.0.4_PUBLIC/Extensions/GhidraMCP/lib/GhidraMCP-5.12.0.jar
CLASSPATH="$MCP_JAR"
for jar in $GHIDRA_HOME/Ghidra/Framework/*/lib/*.jar; do CLASSPATH="${CLASSPATH}:${jar}"; done
for jar in $GHIDRA_HOME/Ghidra/Features/*/lib/*.jar; do CLASSPATH="${CLASSPATH}:${jar}"; done
for jar in $GHIDRA_HOME/Ghidra/Processors/*/lib/*.jar; do CLASSPATH="${CLASSPATH}:${jar}"; done
java -Xmx4g -XX:+UseG1GC \
-Dghidra.home=$GHIDRA_HOME -Dapplication.name=GhidraMCP \
-classpath "$CLASSPATH" \
com.xebyte.headless.GhidraMCPHeadlessServer \
--port 8089 --bind 127.0.0.1 \
--project /home/evan/hamsterball-re/analysis/ghidra/HamsterballProject/Hamsterball.gpr \
--program /Hamsterball.exe \
> /tmp/ghidra-mcp.log 2>&1
Required flags:
--projectmust end in.gpr--programmust have leading slash (/Hamsterball.exe)
4. Verify the Server
# Health check
curl -s http://127.0.0.1:8089/health
# Expected output:
# {"status":"healthy","version":"5.12.0-headless","program_loaded":true,"program_name":"Hamsterball.exe"}
# Rename coverage
curl -s http://127.0.0.1:8089/compare_programs_documentation
# List functions
curl -s "http://127.0.0.1:8089/list_functions?page=1&limit=5000" | head
# Decompile a known function
curl -s "http://127.0.0.1:8089/decompile_function?address=0x004278E0"
5. Hermes MCP Bridge (Optional)
If Hermes does not auto-discover mcp_ghidra_mcp_* tools, enable the bridge in ~/.hermes/config.yaml:
mcp_servers:
ghidra-mcp:
command: python3
args:
- /opt/ghidra-mcp/bridge_mcp_ghidra.py
env:
GHIDRA_SERVER_URL: http://127.0.0.1:8089/
timeout: 180
connect_timeout: 30
Then verify with hermes tools list | grep ghidra.
6. Important Server Quirks
- Use GET with query params, not POST with JSON. Direct POST to
/decompile_functionreturns errors. /list_functionsreturns plain text, not JSON.- Name lookups (
?name=Foo) are unreliable; prefer?address=0x.... run_script_inlineis broken in headless mode due to OSGi BundleHost issues.
7. Stop / Restart
kill $(pgrep -f GhidraMCPHeadlessServer)
# Wait 2 seconds, then re-run the start command.
🔗 Related Documents
03 - Ghidra Setup
03 - Ghidra Setup
Import Any Binary
/opt/ghidra/support/analyzeHeadless /path/to/project ProjectName -import /path/to/binary -overwrite
Auto-Analysis
Run all default analyzers first. For MSVC binaries, enable:
- Windows PE x86 Propagate External Parameters
- Apply Function Signature Data
- Create Address Tables
Project Hygiene
- Name the project after the binary version.
- Keep an untampered copy of the original.
- Export renames to git regularly.
- Use commit messages like
analysis: rename rendering functions @ 0x45xxxx.
Headless Decompilation
Useful for batch work:
/opt/ghidra/support/analyzeHeadless /path/to/project ProjectName -scriptPath ./scripts -postScript DecompileByAddress.java 0x1234 /tmp/out.c
🔗 Related Documents
03 - Restore Function Renames
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/03-RENAME-RESTORE.md)
03 - Restore Function Renames from FUNCTION_MAP.md
The Hamsterball project has 975+ hand-labeled functions. The authoritative list lives in docs/FUNCTION_MAP.md. This file explains how to apply those names to a fresh Ghidra project.
1. Prerequisite
- GhidraMCP headless server running on port 8089
- Program loaded (
program_loaded: trueon/health)
2. Run the Restore Script
cd ~/hamsterball-re
python3 analysis/ghidra/apply_renames.py
The script:
- Parses
docs/FUNCTION_MAP.mdfor| 0x... | Name |rows. - Skips auto-generated names (
Catch,Unwind,operator_,thunk_,entry,Start). - Sends batches of 50 names to
http://127.0.0.1:8089/batch_create_labels. - Prints progress and final documentation percentage.
3. Verify Coverage
curl -s http://127.0.0.1:8089/compare_programs_documentation | python3 -m json.tool
Expected final coverage: ~100% (3,781/3,781 functions documented).
4. Export Renames Back to JSON (After Heavy RE Session)
If you add new labels, back them up:
cd ~/hamsterball-re
python3 - <<'PY'
import json, re
renames = []
with open('docs/FUNCTION_MAP.md', 'r') as f:
for line in f:
m = re.match(r'\|\s*(0x[0-9a-fA-F]+)\s*\|\s*(\S+.*?)\s*\|', line.strip())
if m:
addr, name = m.group(1), m.group(2).strip()
if name not in ('Address', 'Name', '---'):
renames.append({'address': addr, 'name': name})
with open('analysis/ghidra/renames_backup.json', 'w') as f:
json.dump({'renames': renames, 'count': len(renames),
'timestamp': '2026-06-17', 'source': 'FUNCTION_MAP.md'}, f, indent=2)
print(f"Backed up {len(renames)} renames")
PY
5. Maintaining FUNCTION_MAP.md
When you discover and rename a new function:
- Add it to
docs/FUNCTION_MAP.mdin the appropriate subsystem section. - Run
apply_renames.pyto sync Ghidra. - Commit both
docs/FUNCTION_MAP.mdandanalysis/ghidra/renames_backup.json.
6. Format Rules for FUNCTION_MAP.md
## Subsystem Name
| Address | Name | Description |
|---------|------|-------------|
| 0x004XXXXX | FunctionName | What it does |
- Address
0xprefix, 8 hex digits. - Name is one word (no spaces), snake_case allowed.
- Description is concise but specific.
🔗 Related Documents
04 - Decompilation and Code-Re
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/04-DECOMP-WORKFLOW.md)
04 - Decompilation and Code-Reference Workflow
1. Single Address Decompile via REST
ADDR=0x0046BD80 # App_Run
curl -s "http://127.0.0.1:8089/decompile_function?address=$ADDR" > analysis/code_ref/decomp_app_run.c
2. Batch Decompile via Headless Script
When the REST server is slow or unreliable, use a native Ghidra Java script. A template is provided at:
analysis/ghidra/scripts/DecompileByAddress.java
Run it from the repo root:
/opt/ghidra/support/analyzeHeadless \
$(pwd)/analysis/ghidra/HamsterballProject Hamsterball \
-scriptPath $(pwd)/analysis/ghidra/scripts \
-postScript DecompileByAddress.java 0x46EC30 /tmp/decomp_46EC30.c
For multiple addresses, use DecompileMultiAddresses.java (same directory) with a comma-separated address list.
3. Cleaning Decompiled Code
When converting raw Ghidra C to readable reference code:
DO:
- Give variables meaningful names.
- Replace magic numbers with named constants.
- Add section comments (
// === physics integration ===). - Preserve original pointer arithmetic semantics (use
PTR_OFF,READ_U8,WRITE_U8macros).
DO NOT:
- Replace flat
__strnicmpchains withswitch/enum class. - Change
__thiscallfree functions into C++ class methods with early returns. - Factor repeated pointer arithmetic into accessor methods unless the original did so.
See analysis/code_ref/DispatchCollisionEvents_clean.cpp for an example of a faithful cleanup.
4. Required Reading for New Findings
After decompiling a new function, update these canonical docs:
| If you found... | Update... |
|---|---|
| New function | docs/FUNCTION_MAP.md |
| New field / struct | docs/STRUCTS_AND_TYPES.md + analysis/ghidra/structs/*.h |
| Modding behavior | Relevant docs/*_MODDING.md or create new file |
| Rendering behavior | docs/D3D8_RENDERING_PIPELINE.md or docs/RENDERING_ITERATION_LOG.md |
| Input / controls | docs/INPUT_SYSTEM.md |
5. Naming Conventions
| Construct | Convention | Example |
|---|---|---|
| Functions | Subsystem_VerbNoun |
Ball_ApplyForce, Scene_SetCamera |
| Structs | PascalCase | Ball, Scene, CollisionMesh |
| Offsets | UPPERCASE with prefix |
BALL_POS_X, SCENE_BALL_LIST |
| Globals | g_ prefix |
g_app, g_graphics |
6. Cross-Reference Search Patterns
# Find all references to a byte offset in raw decompilations
grep -rn '(int)this + 0xCA8)' analysis/ghidra/decompilations/
grep -rn 'param_1\[0x32a\]' analysis/ghidra/decompilations/ # 0x32a * 4 = 0xCA8
# Find xrefs to a vtable
grep -rn '0x4CF3A0' analysis/code_ref/ docs/ analysis/ghidra/structs/
🔗 Related Documents
04 - Naming Strategy
04 - Naming Strategy
Naming Unknown Functions
| Pattern | Suggested Name |
|---|---|
| Reads a known import | ImportName_Wrapper |
| Calls string search | Subsystem_FindByName |
| Manipulates a known struct | StructName_Action |
| Main loop | Subsystem_Update |
| Only sets a field | Subsystem_SetField |
Be Conservative
If you only know a function calls Direct3DCreate8, name it D3D8_CreateDevice_Wrapper, not Graphics_Initialize unless you prove it initializes the whole subsystem.
Anti-Pattern: Naming by Call Site
A function called from Ball-related code that actually dereferences CollisionMesh fields should be named CollisionMesh_*, not Ball_*. Check which struct offsets this accesses.
🔗 Related Documents
05 - Decompilation Without Dis
05 - Decompilation Without Distortion
The Goal
Produce readable reference code that preserves the original control flow and memory layout.
Cleaning Rules
- Rename variables.
- Name constants and struct offsets.
- Add section comments.
- Keep the original
if-chain order. - Keep the original calling convention.
Forbidden Cleanups
- Replacing flat string-compare chains with
switch. - Changing
__thiscallfree functions into C++ methods with early returns. - Factoring repeated pointer arithmetic into accessors unless the original did so.
- Inventing types for unverified offsets.
🔗 Related Documents
05 - Struct Offset Verificatio
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/05-STRUCT-VERIFICATION.md)
05 - Struct Offset Verification Methodology
Every documented offset in this project is guilty until proven innocent by raw decompiled C. Human-written comments can be wrong.
1. Confidence Markers
| Marker | Meaning |
|---|---|
| ✅ | Verified in raw decompiled C by 2+ distinct functions |
| ⚠️ | Found in exactly 1 decompilation or only in comments |
| ❓ | Inferred from adjacent fields; no raw C evidence yet |
2. Automated Verification Script
A reusable Python verifier is provided at:
python3 docs/agent-knowledge/scripts/verify_struct_offsets.py Scene > /tmp/scene_verify.md
It decompiles the configured list of functions, extracts all (int)this + 0xNNNN and param_1[0xNNN] references, and compares them to the claimed offsets in CLAIMED_OFFSETS.
Template lives at templates/verify_struct_offsets_template.py.
3. Manual Spot-Check Commands
# Direct byte offset in App functions
grep -rn '(int)this + 0x184)' analysis/ghidra/decompilations/app/
# Int-indexed access (multiply by 4)
grep -rn 'param_1\[0x5' analysis/ghidra/decompilations/app/decomp_app_run.c
# Cross-object references (e.g., Ball -> Scene)
grep -rn '0x5dc\|0x5DC' analysis/ghidra/decompilations/ball/
4. Common Offset Confusion Patterns
| Pattern | Example | Fix |
|---|---|---|
| Int-indexed vs byte | param_1[0x5C] = 0x170 |
Multiply index by 4 |
| Nested object flattened | CollisionMesh+0xCA8 under Ball |
Document pointer chain: Ball+0x1A4 → CollisionMesh+0xCA8 |
| Wrong base object | App+0x5DC = Scene* |
Raw C shows Scene+0x5DC player array, not App |
| Stale build | Doc says v1.0 offset; binary v1.1 moved it | Always state binary MD5 in findings |
5. Creating / Extending Structs in Ghidra
The GhidraMCP create_struct API expects a JSON array of fields:
fields = [
{"name": "vtable", "offset": 0x000, "type": "pointer"},
{"name": "owner_ball", "offset": 0x010, "type": "pointer"},
{"name": "roll_friction","offset": 0xC64, "type": "float"},
]
# send to create_struct endpoint
Important: add_struct_field works for extending existing structs. import_data_types is not implemented. create_struct requires the JSON array format shown above; key/value and comma-separated field formats are broken.
6. Plate Comments for ECX this-Param Retyping
Ghidra cannot retype register-based this parameters via the API (set_parameter_type fails on ECX). Workaround: use plate comments on functions to document intended types, or manually retype in the Ghidra GUI.
🔗 Related Documents
06 - Calling Conventions
06 - Calling Conventions
x86 32-bit
| Convention | this/args | Stack cleanup | Typical use |
|---|---|---|---|
__cdecl |
All args on stack | Caller | C functions, variadic |
__stdcall |
All args on stack | Callee | Win32 APIs |
__fastcall |
ECX, EDX, then stack | Callee | Compiler optimization |
__thiscall |
ECX=this, args on stack | Callee | MSVC member functions |
x64
RCX, RDX, R8, R9 for first four integer/pointer args; XMM0-3 for floats. Caller cleans.
ARM64
X0-X7 for args. Stack may be used for extras. Link register X30/LR holds return address.
Detection in Assembly
| Convention | Prologue tells you |
|---|---|
__thiscall |
MOV ESI, ECX saves this |
__stdcall |
RET 0x8 etc. |
__cdecl |
Plain RET, caller pops |
🔗 Related Documents
06 - Common Reverse-Engineerin
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/06-MODDING-PATTERNS.md)
06 - Common Reverse-Engineering Patterns
1. Dead Code Detection
A setter is dead code if the field it writes is never read by the main update/render loop, or if it is overwritten every frame by a computed value.
Method:
- Decompile the setter.
- Find the main update function for that object.
- Search all decompilations for reads of the same offset.
- If zero reads → dead value.
Example: Ball_SetSpeed (historical misname) writes CollisionMesh+0xC64 and +0xC98, but Ball_Update never reads them. The function is actually CollisionMesh_SetFriction and the fields are overwritten by physics.
2. Nested Object Verification
If a constructor does operator_new(size) + SubObject_ctor(...) and stores the result at this + N, that field is a pointer to a nested object.
Pointer chain template:
void* ball = ...;
void** collisionMeshPtr = (void**)((DWORD)ball + 0x1A4);
void* collisionMesh = *collisionMeshPtr;
float* trueVelY = (float*)((DWORD)collisionMesh + 0xCA8);
3. Vtable-Based Function Discovery
Constructors always assign the vtable first. Parse vtable slots as DWORDs to find virtual method addresses.
// Ball vtable at 0x4CF3A0
// [0] Ball_dtor @ 0x4027F0
// [1] Ball_InitPhysicsDefaults @ 0x405100
// [2] probably Update @ ...
A function reached only through vtable dispatch has no direct CALLs. Hook the function start directly or patch the vtable slot.
4. Object Spawning Level-Gating Pattern
Objects fall into two categories:
Self-loading: Constructor calls MeshWorld_ctor(..., "hardcoded_path").
- Can be added to any level by patching
app+0x23Cmode gate. - Examples:
Bonk,Bumper/Rebound.
Scene-dependent: Constructor receives a pre-loaded mesh pointer from a BoardLevel*_ctor.
- Requires both the mode gate patch AND the level constructor to load the sub-mesh.
- Examples:
Tipper,Gluebie,BlockDawg,Catapult,BreakBridge,PopCylinder.
5. Calling Game Functions from Injected Code
Most game member functions use __thiscall:
thisin ECX- Remaining args on stack
typedef void* (__thiscall *Ball_ctor_fn)(void* thisPtr, int scene);
auto BallCtor = (Ball_ctor_fn)0x40AFE0;
void* ball = BallCtor(allocatedMem, (int)scene);
Calling via __cdecl (all args on stack) will crash because ECX contains garbage.
6. The CRT Heap Trap
Never call the game's operator_new / malloc / _free from an injected DLL — the VS2003 Small Block Heap critical sections can deadlock or corrupt.
Fix: Read the private CRT heap handle and use HeapAlloc/HeapFree directly:
HANDLE hCrtHeap = *(HANDLE*)0x005369C0; // image base 0x400000 + RVA 0x1369C0
void* mem = HeapAlloc(hCrtHeap, 0, 0xC98);
For ASLR safety, compute the runtime address: GetModuleHandle(NULL) + RVA.
7. AI as Vtable Override
AI objects reuse the same struct as the player; behavior differences come from config flags and a vtable thunk:
- Level loader writes AI config fields (e.g.,
ball[0x31D] = 1foris_8ball). Ball_Updatechecks the flag and callsBall_ApplyForcevtable slot with AI-computed input.- Look for string comparisons (
__stricmp) against names likeBADBALL,CHASE,HOME,SIZE,SPINDISTANCE.
🔗 Related Documents
07 - Recovering Structs and Gl
07 - Recovering Structs and Globals
Struct Recovery Steps
- Find constructor. Note
operator_new(size)— that is the struct size. - Watch vtable assignment. First field confirms object type.
- Watch sub-object allocations.
this + N = operator_new(...) + ctormeans pointer to nested object. - Watch default writes. They reveal field types.
- Find update/render function to see which fields are read.
Globals
| Location | How to find |
|---|---|
| IAT entries | Import table |
| Singletons | Look for static pointer written once in init |
| CRT heap handle | Trace operator_new → HeapAlloc handle |
Offset Confidence
- ✅ Raw decompiled C, 2+ functions
- ⚠️ One function only or comment-only
- ❓ Inferred from size/alignment, no direct evidence
🔗 Related Documents
07 - Reimplementation Lessons
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/07-REIMPL-LESSONS.md)
07 - Reimplementation Lessons Learned
The project produced a working D3D8 reimplementation and learned a great deal about what does/does not match the original. This file captures those lessons so future work does not repeat the same mistakes.
1. Rendering is D3D8 Only
- The original uses
Direct3DCreate8,IDirect3DDevice8,DrawPrimitiveUP,CreateVertexBuffer. - Any OpenGL/SDL2 files in the old repo are leftovers, not the active code.
- Cross-compile with MinGW i686-w64-mingw32-gcc -m32.
2. Wine + llvmpipe Workaround
Original EXE runs under Wine with a d3d8to9 proxy plus software llvmpipe:
# Build/copy d3d8to9 d3d8.dll next to Hamsterball.exe
Xvfb :99 -screen 0 800x600x16 &
DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 wine Hamsterball.exe
This is useful for screenshot/testing but has known rendering bugs:
D3DRS_LIGHTING=TRUE + D3DFVF_NORMALignores normals → flat surfaces.- Textures may not render even with correct API usage.
3. Lighting Target-Match Approach
Instead of relying on D3D8 material/lighting interpolation (which behaves incorrectly under llvmpipe), define lit/shadow target colors directly:
| Surface | Lit | Shadow |
|---|---|---|
| Wall | (0.72,0.90,1.0) |
(0.24,0.35,0.49) |
| Floor | (0.96,0.96,1.0) |
(0.70,0.78,1.0) |
| Sky | (85,120,215) |
- |
Formula:
float lit_factor = pow(max(dot(normal, light_dir), 0.0f), 0.35f);
Vec3 color = shadow + (lit - shadow) * lit_factor;
4. Camera Sign Bug
Original Scene_SetCamera computes:
orbit_dir = (cos_a, 0.9, sin_a); // camera-to-target direction
eye = target + normalize(orbit_dir) * orbit_dist;
A naive reimplementation placing the eye with the opposite sign (target - dir * dist) works for some levels (Level3) but breaks others (Level1, Level2). Use an adaptive heuristic or match the original sign per level.
5. Spawn / Collision Placement
- Ball spawn Y must be derived from actual geometry (probe downward from START object), not from the START object's raw Y coordinate.
- Level3
START1-1is at Y=-85.4; the track surface is nearby, but blindly spawning at START.y + radius places the ball in empty space.
6. Per-Geometry vs Per-Level Colors
WarmUp (Level1) uses per-geometry pink diffuse on platform faces. Arena levels use Section 1 PLATFORM diffuse colors. Do not hardcode one color scheme globally.
7. Controls
Ball_GetInputForce reads exactly 4 directional DIK codes at InputDevice+0x50C..0x518. There is NO brake key and NO player-jump; jumping is triggered by E:JUMP collision objects only.
8. Avoid Backgrounding Wine Processes
Wine will hang the agent for 50+ minutes if backgrounded. Use timeout 10s, or launch and kill in the same command.
9. Cross-Compiling DSound8
When compiling DSound code with MinGW, include <mmeapi.h> (or <mmreg.h>) before <dsound.h> to get LPWAVEFORMATEX/LPCWAVEFORMATEX.
10. WASM BoxedWine Dead End
BoxedWine WASM cannot run D3D8/D3D9 games in the browser. The WASM port requires a native SDL2/WebGL reimplementation, not the original EXE.
🔗 Related Documents
08 - Object-Oriented Patterns
08 - Object-Oriented Patterns
Vtables
A vtable is an array of function pointers stored in .rdata.
// Constructor assigns vtable first
this->vtable = &KnownVtable;
// Slot N = vtable + N*4 (32-bit) or N*8 (64-bit)
Use vtables to map:
- Destructor slot 0
- Virtual methods 1..N
- Multiple inheritance may have multiple vtables
Constructors / Destructors
// In constructor:
this->vtable = &LiveVtable; // most-derived
SubObject_ctor(this + N);
// In destructor:
this->vtable = &BaseVtable; // base
SubObject_dtor(this + N);
operator_delete(this);
Multiple Inheritance
A class with multiple bases has multiple vtable pointers, often at +0, +N.
Watch for destructors that restore a different vtable than the constructor sets.
🔗 Related Documents
08 - Troubleshooting Known Fai
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/08-TROUBLESHOOTING.md)
08 - Troubleshooting Known Failure Modes
Ghidra / GhidraMCP
Server returns program_loaded: false
- Read
/tmp/ghidra-mcp.log. - Confirm
--projectends in.gprand--programstarts with/. - Re-import with
analyzeHeadlessif the program was never loaded. - After re-import, restore renames from
docs/FUNCTION_MAP.md.
run_script_inline fails with BundleHost error
Inline Python/Ghidra scripts are broken in headless mode. Use native Java scripts in analysis/ghidra/scripts/ instead.
MCP tools missing from Hermes
- Check
~/.hermes/config.yamlfor themcp_servers: ghidra-mcp:bridge block. - Verify
/opt/ghidra-mcp/bridge_mcp_ghidra.pyexists. - Fall back to direct
curlGET requests.
Decompile by name returns "Address or function name is required"
Use ?address=0x... instead of ?name=.... Name lookups are unreliable.
Tools / Environment
pip vs python3 mismatch
This machine has python3=3.11.15 with no pip module; pip points to python3.12. Use uv or a venv for Python dependencies.
Web search / web extract hanging
Ensure FIRECRAWL_API_KEY and FIRECRAWL_API_URL are set in the Hermes config pointing to the Guanaco router (or disable web fallback).
Guanaco router AttributeError (UsageConfig missing fields)
The installed guanaco package is older than the repo code. Fix:
cd ~/.guanaco/repo
source ~/.guanaco/venv/bin/activate
pip install -e .
Binary / RE
Spawned BadBall crashes
- Verify
__thiscallconvention. - Verify ECX = allocated memory.
- Ensure the Scene pointer and CollisionLevel pointer at
Scene+0x440are valid. - Use
HeapAllocon the game's CRT heap, notoperator_newfrom a DLL.
Camera geometry invisible
Try both eye = target + dir*dist and eye = target - dir*dist or sample which side has vertices in front of the camera.
Textures not rendering under Wine/llvmpipe
Programmatic textures via CreateTexture + LockRect work; loaded BMP/PNG textures may not. Use runtime-generated checker textures with D3DTOP_MODULATE for correct per-pixel modulation.
Commit Safety
Never commit
- Original binaries (
.exe,.dll) - Installer packages
- User credentials or API tokens
- Save games or registry data
Always commit
docs/FUNCTION_MAP.mdanalysis/ghidra/renames_backup.jsonanalysis/ghidra/structs/*.h- Clean C++ reference reconstructions in
analysis/code_ref/
🔗 Related Documents
09 - Dynamic Analysis
09 - Dynamic Analysis
Debugger Uses
- Set breakpoint on suspected function, observe args.
- Watch memory writes to structs.
- Capture call stack for crashes.
- Trace API calls (CreateFile, LoadLibrary, etc.).
Hooking
| Method | Best For |
|---|---|
| DLL injection | Long-term interception |
| IAT hooking | Replacing imported APIs |
| Inline hooking | Replacing internal functions |
| Frida | Scriptable tracing |
Safe Hook Checklist
- Match calling convention exactly.
- Preserve registers not used by your hook.
- Do not call CRT allocators from injected threads if the target uses a private heap.
- Log before changing state.
🔗 Related Documents
10 - Subsystem Patterns
10 - Subsystem Patterns
Graphics
Look for Direct3DCreate8/9, CreateWindowEx, RegisterClass, wglCreateContext, SDL_CreateWindow.
Input
Look for DirectInput8Create, GetAsyncKeyState, RegisterRawInputDevices, XInput.
Audio
Look for DirectSoundCreate, BASS_Init, waveOutOpen, OpenAL.
File Formats
Binary formats often follow:
- Magic / version
- Count fields
- Fixed-size records or length-prefixed strings
- Float arrays (3 or 16 floats for 3D transforms)
Event Systems
Many older games use flat string comparison chains:
if (__stricmp(name, "E:JUMP") == 0) { ... }
if (__stricmp(name, "E:ACTION") == 0) { ... }
🔗 Related Documents
11 - Verification
11 - Verification
Every Claim Needs Evidence
| Claim | Required Evidence |
|---|---|
| Function name | Decompilation or direct xrefs |
| Struct size | Constructor operator_new(size) |
| Field offset | Raw C statement (this + N) or (this)[N*4] |
| Field meaning | Observed values and read/write context |
| Calling convention | Prologue/disassembly |
| Vtable slot | Parsed vtable array at known address |
Falsification
Actively look for evidence against your hypothesis:
- Search other functions for the same offset.
- Check if a setter's field is ever read.
- Check if a flag is overwritten every frame.
Peer-Review Checklist
Before committing a finding:
- Can another agent reproduce the address from the doc?
- Is the binary hash stated?
- Are confidence markers applied to every claim?
- Are wrong guesses removed rather than left commented out?
🔗 Related Documents
12 - Troubleshooting Common De
12 - Troubleshooting Common Dead Ends
Decompilation Looks Nonsensical
- Check that the binary base is correct.
- Check that you are analyzing the right code (thumb vs ARM, 16-bit vs 32-bit).
- Look at raw disassembly alongside decompilation.
Function Has No Xrefs
- It may be reached through a vtable.
- It may be an export or callback.
- It may be dead code.
Strings Do Not Match Behavior
- Strings may be old or unused.
- The same string may have multiple encoding blocks.
- Look for the code that references the string, not the string itself.
Tool Hangs
- Don't background long-running native tools.
- Use
timeout. - For servers, use Hermes
background=truewith health checks.
Dynamic Analysis Crashes
- Verify calling convention.
- Verify stack alignment.
- Check for anti-debug (TLS callbacks, timing checks, debug registers).
🔗 Related Documents
8-Ball AI System
Hamsterball 8-Ball AI System
Overview
The "8-ball" or "BADBALL" is an AI-controlled enemy ball that appears in single-player race levels. It is a fully autonomous ball object with a simple chase-and-spin behavior designed to interfere with the player. The AI is stateless — no pathfinding, no planning, just a position-seeking force vector updated every frame.
Key insight: The 8-ball is NOT a separate object type. It is a standard Ball instance with NPC flags set. The AI code lives inside the same Ball_Update function (vtable [0x10] at 0x408390 wrapping 0x405E00) that runs on every ball every frame. For player balls, the AI block is skipped because is_8ball = 0. There is no separate 8-ball update function — all balls share the same vtable and the same tick.
Critical consequence: If you set ball[0x31D] = 1 on a player ball, that player ball will execute the 8-ball AI and chase other balls automatically. The AI is a conditional block inside the universal update, not a separate object system.
BADBALL MESHWORLD Format
In MESHWORLD level data, BADBALL objects are defined with typed parameters:
BADBALL pos_x pos_y pos_z
CHASE distance
HOME distance
SIZE radius
SPINDISTANCE radius
Parameters
| Tag | Type | Default | Description |
|---|---|---|---|
CHASE |
float | 0 | Max distance to target — AI shuts down if player is farther than this |
HOME |
float | 0 | Max distance from spawn — AI shuts down if 8-ball is farther than this from its HOME position |
SIZE |
float | 27 | Ball radius (overrides default) |
SPINDISTANCE |
float | 0 | Radius of the circular "spin" wobble added to target position |
Example from Level Data
BADBALL 120.0 50.0 200.0
CHASE 800
HOME 400
SPINDISTANCE 50
This 8-ball:
- Spawns at (120, 50, 200)
- Only chases when player is within 800 units
- Only chases when itself is within 400 units of spawn
- Adds a 50-unit circular wobble to its target position
AI Activation Conditions
The 8-ball AI runs inside Ball_Update (vtable slot [0x10] at 0x408390) ONLY when either condition is true:
if (ball[0x31d] != 0 || scene[0x237] != 0) {
// Run 8-ball AI
}
| Condition | Meaning | When Set |
|---|---|---|
ball[0x31d] != 0 |
Ball was spawned as a BADBALL | Set by CreateBadBall() when parsing MESHWORLD |
scene[0x237] != 0 |
Battle mode / multiplayer | Set by Ball_InitBattleMode() for CPU players |
Key: 0x31d is set to 1 during CreateBadBall() and cleared during level transitions. The AI block is skipped entirely for normal player balls.
AI Behavior: Two Modes
Mode 1: Direct Chase (when no BADBALL config)
If no CHASE/HOME parameters were set, the AI simply seeks the nearest valid ball:
// Find nearest valid target ball
float best_dist = 999999.0;
Ball* target = nullptr;
for (each ball in scene->ball_list) {
if (ball->race_active && // [0x768] — ball is in active race
!ball->is_falling && // [0x2f9] — not currently falling/respawning
ball->player_index != -1 && // [0x18] — has a valid player
!ball->is_teleporting && // [0x324] — not in teleport
scene->some_mode_flag) { // [0x3a4c]
float dist = Math_FastDistance2D(8ball->pos.x, 8ball->pos.z,
ball->pos.x, ball->pos.z);
if (dist < best_dist) {
best_dist = dist;
target = ball;
}
}
}
Target validity check breakdown:
race_active (0x768): Ball is actively racing (not in menu, not finished)!is_falling (0x2f9): Ball is not in a fall/respawn stateplayer_index != -1 (0x18): Ball has a player/controller assigned!is_teleporting (0x324): Ball is not currently teleportingscene[0x3a4c]: Some scene-level mode flag (possibly "race in progress")
Mode 2: BADBALL Configured Chase
When CHASE and HOME are set, additional distance checks gate the behavior:
// Only chase if:
// 1. Random roll succeeds (RNG < threshold)
// 2. Player is within CHASE distance
// 3. 8-ball is within HOME distance from its spawn
float rng = RandomFloat();
if (rng < threshold &&
dist_to_player < ball[0x31c] && // CHASE parameter
dist_from_home < ball[0x31b]) { // HOME parameter
target = player_ball;
}
Target Computation: Spin Wobble
The 8-ball's target position is NOT the player's exact position. It adds a circular wobble using SPINDISTANCE:
// Base target = spawn position (HOME position, stored at [0x318/0x319/0x31A])
Vec3 target = {
ball[0x318], // spawn X
ball[0x319], // spawn Y
ball[0x31A] // spawn Z
};
// Add spin wobble
float spin_angle = ball[0x31e] * spin_speed; // [0x31e] increments each frame
target.x += sin(spin_angle) * ball[799]; // ball[799] = SPINDISTANCE
target.z += cos(spin_angle) * ball[799];
// Increment spin counter
ball[0x31e] += 0.05; // _DAT_004cf48c ≈ 0.05
Key insight: The spin makes the 8-ball orbit around its HOME point while chasing, creating a "probing" behavior rather than a direct homing missile.
Force Application
Once the target is computed, the AI applies force toward it:
// Compute direction to target
float dx = target.x - ball->pos.x; // [0x59]
float dy = target.y - ball->pos.z; // [0x5B] — note: using Z as Y in this code
float dz = target.z - ball->pos.y; // [0x5A]
// Normalize
float dist_sq = dx*dx + dy*dy + dz*dz;
float dist = sqrt(dist_sq);
if (dist > 1.0) {
dx /= dist;
dy /= dist;
dz /= dist;
}
// If 8-ball is smaller than target ball, REVERSE direction (flee!)
if (8ball->radius < target->radius * 0.9) {
dx = -dx;
dy = -dy;
dz = -dz;
}
// Apply force via vtable call
// vtable[0x14] = Ball_ApplyForceWithMultipliers(this, dirX, dirY, dirZ, multiplier)
ball->vtable[0x14](ball, dx, 0.0, dz);
Flee behavior: If the 8-ball's radius is smaller than the target's radius × 0.9, it runs away instead of chasing. This can happen with power-up balls or when the player has grown.
Ball Struct: AI-Relevant Fields
8-Ball Config (set by CreateBadBall())
| Byte Offset | Int Index | Type | Name | Description |
|---|---|---|---|---|
| +0xC60 | [0x318] | Vec3 | home_position | Spawn position (X/Y/Z) |
| +0xC6C | [0x31B] | float | home_distance | HOME parameter — max distance from spawn |
| +0xC70 | [0x31C] | float | chase_distance | CHASE parameter — max distance to target |
| +0xC74 | [0x31D] | bool | is_8ball | Set to 1 by CreateBadBall(), cleared on level transition |
| +0xC78 | [0x31E] | float | spin_counter | Increments each frame, drives spin wobble angle |
| +0xC7C | [0x31F] | float | spin_distance | SPINDISTANCE parameter — radius of spin wobble |
Physics/Collision (shared with all balls)
| Byte Offset | Int Index | Type | Name | Description |
|---|---|---|---|---|
| +0x164 | [0x59] | float | pos.x | Current X position |
| +0x168 | [0x5A] | float | pos.y | Current Y position |
| +0x16C | [0x5B] | float | pos.z | Current Z position |
| +0x284 | [0xA1] | float | radius | Ball collision radius |
| +0x2F0 | [0xBC] | int | impact_counter | Frames since last impact (damps force) |
| +0x2F9 | [0x7E] | bool | is_falling | Currently in fall/respawn |
| +0x324 | [0xC9] | bool | is_teleporting | Currently teleporting |
| +0x768 | [0x1DA] | bool | race_active | Ball is in active race |
| +0xC28 | [0x30A] | char* | display_name | Ball name string (for score display) |
| +0xC5C | [0x317] | int | battle_mode_flags | Friction/speed modifiers for battle |
Scene Struct: AI-Relevant Fields
| Byte Offset | Int Index | Type | Name | Description |
|---|---|---|---|---|
| +0x237 | [0x8D] | bool | battle_mode | Set when in battle/multiplayer mode |
| +0x3A4C | [0xE93] | bool | race_in_progress | Some mode flag checked for target validity |
| +0x29D4 | [0xA75] | AthenaList | ball_list | List of all balls in the scene |
| +0x29D8 | [0xA76] | int | ball_count | Number of balls |
| +0x2DE0 | [0xB78] | void** | ball_array | Pointer array to ball objects |
Key Functions
| Address | Name | Role |
|---|---|---|
0x40BCA0 |
CreateBadBall() |
Scans MESHWORLD for BADBALL objects, constructs Ball with NPC flags |
0x408390 |
Ball_AI_Update() |
Vtable slot [0x10] — contains the chase AI logic |
0x405E00 |
Ball_Update() |
Main physics tick, calls AI update if conditions met |
0x456CD0 |
Ball_InitBattleMode() |
Sets battle parameters (speed, friction, battle flags) |
0x401660 |
Ball_SetName() |
Sets display name (used for score announcements) |
0x402650 |
Ball_ApplyForceWithMultipliers() |
Vtable [0x14] — applies directional force |
0x458130 |
Math_FastDistance2D() |
Fast approximated 2D distance (no sqrt, used for target selection) |
0x457DA0 |
Wave_Sin() |
Sine lookup for spin wobble |
0x457DC0 |
Wave_Cos() |
Cosine lookup for spin wobble |
RNG and "Randomness"
The AI uses the game's global RNG (FUN_004BA754) for two purposes:
- Target selection jitter: Random float < 0.5 gates whether the AI runs at all this frame
- Score event RNG: When an 8-ball hits a player, RNG determines score bonus and plays a random taunt sound
// When 8-ball hits a player ball:
float rng = RandomFloat();
if (rng < 0.5) {
AddScore(2000); // Bonus points
PlayRandomTauntSound(); // "Gotcha!" etc.
target_ball->score += rng * 100; // Small random bonus
}
Decompilation Sources
All analysis is derived from raw Ghidra decompiled C:
analysis/ghidra/decompilations/ball/decomp_ball_vtable_0x408390.c— Main AI update logicanalysis/ghidra/decompilations/scene/decomp_createbadball.c—CreateBadBall()functionanalysis/ghidra/decompilations/ball/decomp_ball_vtable_update_physics.c— Ball struct field mapanalysis/ghidra/decompilations/ball/decomp_ball_initbattlemode.c— Battle mode initialization
Modding Notes
To disable 8-balls in a level:
Option 1 — Remove from MESHWORLD: Delete the BADBALL object entries from the level file.
Option 2 — Patch at runtime: Set ball[0x31D] = 0 for all 8-ball instances. The AI block is skipped.
Option 3 — Patch the scene: Set scene[0x237] = 0 to disable battle mode AI entirely.
To make 8-balls more aggressive:
- Increase
CHASEdistance (default is often 0 = unlimited) - Decrease
HOMEdistance (makes them chase from farther spawn radius) - Increase
SPINDISTANCE(makes them wobble more wildly) - Patch
0x408390to remove the RNG gate (always chase instead of 50% chance)
To make 8-balls flee instead of chase:
Set their radius very small: ball[0xA1] = 5.0f (player radius is ~27). The flee branch triggers when 8ball_radius < target_radius * 0.9.
Verified Confidence Markers
| Claim | Marker | Evidence |
|---|---|---|
CreateBadBall scans for BADBALL string |
✅ | Raw C string compare in decomp_createbadball.c |
CHASE/HOME/SIZE/SPINDISTANCE parsed |
✅ | Raw C __stricmp + atol chain in decomp_createbadball.c |
ball[0x31D] gates AI execution |
✅ | Raw C if (param_1[0x31d] != 0) in decomp_ball_vtable_0x408390.c |
ball[0x318..0x31A] = HOME position |
✅ | Copied from MESHWORLD spawn pos in CreateBadBall |
ball[0x31E] = spin counter |
✅ | Incremented each frame, passed to Wave_Sin/Wave_Cos |
Spin wobble uses SPINDISTANCE |
✅ | ball[799] (0xC7C) × sin/cos added to target |
| Flee when smaller radius | ✅ | if (8ball_radius < target_radius * 0.9) { negate direction } |
| Target validity checks | ✅ | 5-condition compound if in raw C |
| Force applied via vtable[0x14] | ✅ | (**(code **)(*param_1 + 0x14))(...) call |
Math_FastDistance2D is approximate |
✅ | Raw C: max*0.96 + min*0.4 approximation |
🔗 Related Documents
8-Ball Hit Detection Mod
8-Ball Hit Detection Mod
Detects whenever a player ball collides with an 8-ball (NPC). Pure detection only — no gameplay changes.
How It Works
Hooks Ball_Update (0x405E00) at address 0x406FD1 — the start of the ball-ball collision scoring section. At this point in the code:
- ESI = this ball (running Ball_Update)
- EDI = other ball (collision partner)
- Both balls are confirmed colliding (past the
collision_type == 1check)
The mod checks ball+0x18 (player_index) on both balls:
0-3= Player 1-4-1(0xFFFFFFFF) = NPC 8-ball
If exactly one ball is a player and the other is an 8-ball, it increments g_hit_count and appends a line to hitlog.txt in the game directory.
Uses pointer comparison (ESI < EDI) to avoid double-counting, since Ball_Update runs for both balls in a collision pair.
Log Output
The mod writes to hitlog.txt in the Hamsterball game directory. Example:
[Hit 1] Player 1 struck an 8-ball
[Hit 2] Player 1 struck an 8-ball
[Hit 3] Player 2 struck an 8-ball
Installation
- Rename the original
bass.dll→bass_real.dllin the Hamsterball directory - Copy the mod's
bass.dllto the same directory - Launch Hamsterball.exe
Build
i686-w64-mingw32-gcc -shared -o bass.dll 8ball_hit_detect.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc -Wl,--add-stdcall-alias
Technical Details
| Item | Value |
|---|---|
| Hook address | 0x00406FD1 |
| Original instruction | fld dword [edi+0x284] (6 bytes) |
| Hook type | JMP code cave (5-byte JMP + 1 NOP) |
| Activation delay | 5 seconds after DLL load |
| Player index offset | ball+0x18 (int: -1 = NPC, 0-3 = player) |
| Hit counter | g_hit_count (volatile DWORD, readable via debugger) |
Verification
Hook site bytes at 0x406FD1 (must match before patching):
D9 87 84 02 00 00
🔗 Related Documents
8-Ball Spawn Mod
8-Ball Spawn Mod
Press B during gameplay to spawn an 8-ball in front of your hamster ball. The 8-ball spawns with physics and rolls/collides just like the balls in Rodent Rumble arenas.
What's New
- Audio forwarding: All 10 BASS functions the game imports are forwarded to
bass_real.dll. The game now has full audio while the mod is active. - Single-ball overwrite: Only one 8-ball can exist at a time. Pressing B again repositions the existing 8-ball to your current location and velocity — no new ball is created.
- Dead AI fields documented: CHASE/HOME/SPINDISTANCE values are set but have no effect because
is_8ball(ball+0xC74) is never set to 1. The ball is a physics-only debris ball.
Installation
- In your Hamsterball game folder, rename
bass.dll→bass_real.dll - Copy the mod's
bass.dllinto the game folder - (Optional) Copy
8ball_spawn.ininext tobass.dllfor configuration - Launch the game, enter a level or arena, press B to spawn an 8-ball
How It Works
The 8Ball mesh is preloaded by the game's resource loader into the board mesh array at board+0x268 (index 9 of the array at board+0x244). The ball mesh index field ball+0x754 controls which mesh the ball uses — setting it to 9 makes the ball render as the 8-ball.
Ball creation follows the same pattern as CreateBadBall (0x40BCA0):
operator_new(0xC98)allocates ball memoryBall_ctor(mem, scene)constructs the ballvtable[1]()initializes physics defaults- Position is set in front of the player's ball
ball+0x754 = 9selects the 8Ball meshball+0x18 = -1sets player_index to none (debris ball)- Player's exact velocity vector is copied (same direction and speed)
- Radius stays at
Ball_InitPhysicsDefaultsdefault (35.0) - Ball is added to
scene+0x29D4(bad_balls_list) andscene+0x2DEC(all_balls_list)
Single-Ball Overwrite
If a previously spawned 8-ball is still valid (vtable check against 0x4CF3A0), pressing B again repositions it to the new spawn location with the player's current velocity. No new allocation is made — the same ball object is reused. If the old ball has been freed or corrupted, a fresh ball is allocated.
Audio Forwarding
The mod loads bass_real.dll on the first BASS function call (not in DllMain, to avoid loader lock deadlocks on real Windows). All 10 functions the game imports are forwarded with correct __stdcall calling conventions:
| Function | Forwarded |
|---|---|
| BASS_Init | ✅ |
| BASS_Free | ✅ |
| BASS_Start | ✅ |
| BASS_Stop | ✅ |
| BASS_SetConfig | ✅ |
| BASS_ErrorGetCode | ✅ |
| BASS_MusicLoad | ✅ |
| BASS_MusicPlayEx | ✅ |
| BASS_ChannelSetAttributes | ✅ |
| BASS_ChannelStop | ✅ |
If bass_real.dll is not found, all functions return success values (1/0) — the game runs without audio but does not crash.
AI Fields (Dead Code)
The mod sets CHASE=100000, HOME=100000, SPINDISTANCE=1.0 on the spawned ball. However, these fields have no effect because:
ball+0xC74(is_8ballflag) is never set to 1- Without that flag,
Ball_Update(0x408390) skips the AI chase block entirely viaif (ball[0x31d] != 0 || scene[0x237] != 0) - The ball behaves as a physics-only debris ball — it rolls, falls, and collides but never actively chases other balls
To enable AI behavior, ball+0xC74 would need to be set to 1. This would make the 8-ball chase the nearest player using the CHASE/HOME/SPINDISTANCE parameters.
Configuration
Edit 8ball_spawn.ini (placed next to bass.dll):
[8ball]
spawn_key=0x42 ; B key (default). See VK_* constants on MSDN
spawn_distance=40 ; Distance in front of player
Build
i686-w64-mingw32-gcc -shared -o bass.dll 8ball_spawn.c \
-lwinmm -Wl,--enable-stdcall-fixup \
-O2 -static -static-libgcc -Wl,--add-stdcall-alias
Files
| File | Description |
|---|---|
8ball_spawn.c |
Mod source code |
8ball_spawn.ini |
Configuration file |
bass.dll |
Compiled mod (proxy bass.dll) |
Technical Notes
- BASS_SetConfig/BASS_GetConfig are now forwarded to bass_real.dll (previously stubbed). The game calls BASS_SetConfig via SEH-protected code — forwarding with correct
__stdcallconvention works safely. - Scene/Board discovery uses brute-force App struct scan for the Scene vtable (0x4D0260), same approach as the player_clones mod.
- The 8-ball has no AI — it spawns as a physics debris ball with player_index = -1. It rolls, falls, and collides with other balls and geometry.
load_real_bass()is called fromspawn_thread(not DllMain) to avoid the Windows loader lock deadlock that affectsLoadLibraryAcalls during DLL initialization.
🔗 Related Documents
8ball_goal_fix
8ball_goal_fix
Prevents crash when 8-ball (BadBall) touches an N:GOAL trigger.
Root Cause
BadBalls (8-balls) have player_index = -1 (set in Ball_ctor2 at 0x4039E0, never overwritten by CreateBadBall). When a BadBall hits an N:GOAL trigger, DispatchCollisionEvents (0x40C5D0) uses player_index as an array index:
addr = player_index * 0xA0 + App
With player_index = -1, this computes addr = App - 0xA0, then writes to three offsets: App+0x536, App+0x550, App+0x55C — corrupting App state and crashing the game.
Raptisoft already knew about this problem: the E:LIMIT handler in the same function (at 0x0040C785) has a CMP ECX,-1 / JZ skip guard. They just forgot to add the same guard to the N:GOAL and E:ACTION(SCORE) handlers.
Fix
Three code caves that replicate the E:LIMIT pattern: check player_index < 0 before per-player writes, and if so, skip to the safe code (camera repositioning, status strings — which don't use player_index).
| Patch | Address | What it guards | Skip target |
|---|---|---|---|
| 1 | 0x0040CF64 | N:GOAL "finished" flag (App+0x5D6, App+0x5FC) | 0x0040CFA0 (camera) |
| 2 | 0x0040D03A | N:GOAL "show results" flag (App+0x5F0) | 0x0040D05E (epilogue) |
| 3 | 0x0040CA33 | E:ACTION(SCORE) score write (App+0x5E4) | 0x0040CA74 (loop continue) |
Result
- 8-ball crossing goal: goal music plays, camera repositions, board flag set ✅
- Player crossing goal after: race finishes normally with results screen ✅
- No crash ✅
- Identical visible behavior to original game minus the crash ✅
Installation
- Rename original
bass.dlltobass_real.dll - Copy this mod's
bass.dllinto the game folder - Launch Hamsterball.exe
Build
i686-w64-mingw32-gcc -shared -o bass.dll 8ball_goal_fix.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc -Wl,--add-stdcall-alias
Crash Test
Passed via hbtestd (35s survival, process alive). Tested on Wine/Xvfb/llvmpipe.
🔗 Related Documents
A1 - __thiscall Convention for
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/A1-THISCALL.md)
A1 - __thiscall Convention for Hamsterball
All member functions (constructors, destructors, vtable methods) in Hamsterball.exe use __thiscall.
Detection
Prologue pattern: PUSH ECX; PUSH ESI; MOV ESI, ECX saves this from ECX.
Correct Usage
typedef void* (__thiscall *Ball_ctor_fn)(void* thisPtr, int scene);
auto BallCtor = (Ball_ctor_fn)0x40AFE0;
void* ball = BallCtor(allocatedMem, (int)scene);
// vtable[1] init defaults
auto InitPhysics = (void (__thiscall *)(void*))0x405100;
InitPhysics(ball);
Constructor Chain
Ball_ctor(0x40AFE0) is the public constructor for badballs.- It calls
Ball_ctor2(0x4039E0) internally, then overwrites the vtable to the live Ball vtable (0x4CF3A0). - Do NOT call
Ball_ctor2manually when usingBall_ctor.
Heap Allocation
Avoid operator_new from injected code. Use:
HANDLE hCrtHeap = *(HANDLE*)0x005369C0;
void* mem = HeapAlloc(hCrtHeap, 0, 0xC98);
Compute runtime address with GetModuleHandle(NULL) + RVA for ASLR safety.
🔗 Related Documents
A2 - Object Spawning and Level
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/A2-OBJECT-SPAWNING.md)
A2 - Object Spawning and Level Gating
See also docs/LEVEL_LOCKED_OBJECTS.md.
Self-Loading Objects
Constructor calls MeshWorld_ctor(..., "levels\\level5-bonk") with a hardcoded path.
| Object | Constructor | File |
|---|---|---|
| Bonk | 0x438850 | levels\level5-bonk |
| Bumper | 0x40FA20 | levels\level8 |
| Bumper2 | 0x413CE0 | levels\arena-beginner |
Enable on any level by patching app+0x23C to non-zero and adding the object name to the MESHWORLD file.
Scene-Dependent Objects
Constructor takes a pre-loaded mesh pointer from the scene struct. The pointer is populated only by a specific BoardLevel*_ctor.
| Object | Constructor | Scene Offset | Loaded By |
|---|---|---|---|
| Tipper | 0x437960 | scene+0x4394/0x4398 | BoardLevel3 |
| Catapult | 0x437E10 | scene+0x5848 | BoardLevel |
| Gluebie | 0x437CB0 | scene+0x607C | BoardLevel3 |
| BlockDawg | 0x43C310 | scene+0x5840/5844 | BoardLevel |
| BreakBridge | 0x436D70 | scene+0x5410/5414 | BoardLevel |
| PopCylinder | 0x436EE0 | scene+0x5420 | BoardLevel |
To add these to a level, both app+0x23C and the level constructor sub-mesh load must be patched.
🔗 Related Documents
A3 - [[21709218365663|camera system]] Pitfalls
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/A3-CAMERA-PITFALLS.md)
A3 - Camera System Pitfalls
Scene+0x3F1C (path_follow_mode) is not a camera smoothing toggle. It is a pointer check for a CAMERALOCUS object; if the object exists, the camera target blends along a spline. If absent, the camera target is the ball. The perceived "smooth follow" comes from the ball's display position interpolation, not this flag.
Actual camera parameters:
| Offset | Name | Effect |
|---|---|---|
| 0x29BC | camera_orbit_angle | Fixed orbit direction (cos, 0.9, sin) |
| 0x29C0 | camera_orbit_dist | Distance from target to eye |
Original Scene_SetCamera places the camera at:
orbit_dir = (cos_a, 0.9, sin_a);
eye = target + normalize(orbit_dir) * orbit_dist;
In reimpl, Level1/2 work with this sign; Level3 requires the opposite sign because its geometry lies mostly at Z < ball.Z. An adaptive sign heuristic or level-specific override is needed.
🔗 Related Documents
Adding New Controls to the Rem
Hamsterball — Adding New Controls to the Remap Menu (DLL Modding Guide)
Scope: Original Hamsterball.exe (PE32, i386, Athena engine).
Method: MinHook-based DLL injection into live binary.
Last Updated: 2026-06-02
Table of Contents
- What This Guide Covers
- Architecture Summary
- Prerequisite: Existing Knowledge
- The Six Hook Points
- Hook Point 1 — Render Extra Control Rows
- Hook Point 2 — Menu Selection Bounds
- Hook Point 3 — Key Capture (Remap)
- Hook Point 4 — Per-Frame Input Polling
- Hook Point 5 — Action Execution
- Hook Point 6 — Registry Persistence
- Complete Example: Adding a "Brake" Key
- MinHook Skeleton
- Address Quick Reference
What This Guide Covers
The original game hardcodes exactly four directional controls (left/right/up/down) in the remap menu, the registry, the physics code, and the UI renderer. Adding a fifth control (e.g. brake, jump, camera reset) requires six coordinated hook points working together. This document gives you every address, offset, and C++ snippet you need.
Architecture Summary
The game’s input pipeline is single-threaded:
InputDevice_PollAndRelease (0x46EBD0)
→ fills 256-byte DI8 state buffer at InputDevice+0x0C
Ball_GetInputForce (0x46EC30)
→ reads 4 DIK codes from InputDevice+0x50C..0x518
→ writes (force_x, force_y) into Ball+0x170 (velocity)
OptionsMenu_RenderControls (0x42E840)
→ draws 4 rows of control icons at hard-coded Y intervals
The 4-key limit is baked into:
- UI loop: iterates
i = 0..3 - Registry: only keys
CONTROL1..CONTROL4 - Struct:
InputDevice+0x50Cis the start of a fixed 4-slot array - Physics:
Ball_GetInputForceonly checks those 4 offsets
Your DLL must intercept all four layers.
Prerequisite: Existing Knowledge
Before reading this guide you should already understand:
InputDevicelayout (size 0x91C, DIK codes at +0x50C/+0x510/+0x514/+0x518)Balllayout (velocity at +0x170, acceleration at +0x2B8)App+0xB28..0xB34stores the 4CONTROLDWORDsOptionsMenu_RenderControlsdraws the remap UI- The game uses DirectInput 8 exclusively (single import at
0x47C7F0)
If any of the above is unfamiliar, read docs/INPUT_SYSTEM.md first.
The Six Hook Points
| # | Hook Target | Address | What You Change |
|---|---|---|---|
| 1 | OptionsMenu_RenderControls |
0x42E840 | Draw rows 5+ below the original 4 |
| 2 | Menu selection bounds check | find via xref | Change max index from 3 → N-1 |
| 3 | Key capture / remap writer | find via xref | Redirect index ≥4 to your custom storage |
| 4 | InputDevice_PollAndRelease |
0x46EBD0 | After original poll, scan your custom DIK codes |
| 5 | Ball_GetInputForce |
0x46EC30 | Apply custom action (e.g. decelerate) |
| 6 | Registry save/load | 0x4284C0 | Read/write extra keys alongside CONTROL1-4 |
Note: Hook points 2 and 3 do not have a single canonical address because they are inlined into the menu state-machine. We describe how to locate them with a pattern search.
Hook Point 1 — Render Extra Control Rows
Target
OptionsMenu_RenderControls at 0x42E840.
What the original does
The function iterates a 4-item loop. For each slot i = 0..3 it:
- Reads
App+0xB28 + i*4to get the bound device type - Draws the action name ("LEFT", "RIGHT", "UP", "DOWN")
- Draws the bound key icon
- If two slots share the same device, colours one red
Your hook strategy
Do not patch the loop count. Instead, let the original 4 rows render completely, then in your post-hook draw rows 5+ using the same coordinate math.
The vertical spacing between rows is a constant pixel offset (read from the original assembly or infer from the decompilation). In the original, each row increments Y by roughly 32 pixels.
C++ Hook Body
typedef void (__thiscall *tOptionsMenu_RenderControls)(void* pMenu);
tOptionsMenu_RenderControls oRenderControls;
void __fastcall hkRenderControls(void* pMenu) {
// 1. Let original draw the 4 built-in rows
oRenderControls(pMenu);
// 2. Append custom rows
// pMenu is the OptionsMenu/SimpleMenu object.
// The renderer uses a coordinate system where (0,0) is top-left.
// Original row 3 ends at Y ≈ base_y + 3*32.
// We start custom rows at Y ≈ base_y + 4*32.
for (int i = 0; i < g_CustomControlCount; ++i) {
int rowY = g_BaseControlY + (4 + i) * g_RowHeight;
// Draw action name (e.g. "BRAKE")
DrawMenuString(pMenu, g_CustomControls[i].name,
g_NameColumnX, rowY,
g_NormalColor);
// Draw bound key name (e.g. "SPACE")
const char* keyName = DIKToString(g_CustomControls[i].dik);
DrawMenuString(pMenu, keyName,
g_KeyColumnX, rowY,
g_NormalColor);
}
}
Where to get the draw helper
The game already has a string-rendering helper used by OptionsMenu_RenderControls. Look for the call to Font_DrawString or AthenaString_Render inside the original function and call the same address from your hook. The exact helper address varies by build; find it by reading the first few xrefs inside 0x42E840.
Hook Point 2 — Menu Selection Bounds
Target
The menu navigation code that clamps the selected row index.
How to find it
- In Ghidra, xref
OptionsMenu_RenderControls(0x42E840) backwards. - Look for the function that calls it — this is the menu update/tick function (likely named
OptionsMenu_Updateor simply a vtable slot onSimpleMenu). - Inside that function, search for an immediate comparison against
3or4near code that reads keyboard input (DIK_UP / DIK_DOWN). - The pattern looks like:
cmp eax, 3 ; or cmp eax, 4 jle already_valid mov eax, 3 ; clamp to max
Patch strategy
Option A — Inline patch (simplest)
Change the immediate 3 to N-1 (e.g. 4 if you added 1 custom control). This is a single-byte patch at the comparison instruction.
Option B — Hook the update function
Hook the entire menu update vtable slot, call original, then if the returned index is ≥4 verify it against your custom count instead of the hardcoded 4.
Warning: The menu also highlights the selected row with a flashing colour. Make sure the highlight-draw code uses the same index bounds; it is usually right after the clamp check.
Hook Point 3 — Key Capture (Remap)
Target
The code that captures a keypress during remap mode and stores the DIK code.
How to find it
When the user clicks a control row, the game enters a "waiting for key" state. During this state it polls the DI8 buffer (InputDevice+0x0C) for any key that transitions from 0 → 0x80. Once found, it stores that DIK code into InputDevice+0x50C + index*4.
Search in the same menu update function for:
- A write to
[reg+0x50C]or[reg+0x510] - The index register is usually
eaxorecxholding0..3 - The value being written is read from
InputDevice+0x0C + DIK
Patch strategy
Hook the write instruction. If index < 4, pass through to original. If index >= 4, write into your own array instead.
C++ Hook Body (inline detour at the writer)
// Global storage for custom bindings (max 8 extra controls)
struct CustomBinding {
BYTE dik; // DIK code (e.g. DIK_SPACE = 0x39)
bool isDown; // polled state
};
CustomBinding g_CustomControls[8];
int g_CustomControlCount = 1; // e.g. 1 = only Brake
// Hook target: the instruction that does
// mov [InputDevice+0x50C+index*4], newDik
void __stdcall hkWriteBinding(int pInputDevice, int index, BYTE newDik) {
if (index < 4) {
// Original 4 controls — write to game memory
*(BYTE*)(pInputDevice + 0x50C + index*4) = newDik;
} else {
// Custom control — write to our array
int customIdx = index - 4;
if (customIdx < g_CustomControlCount) {
g_CustomControls[customIdx].dik = newDik;
}
}
}
Duplicate-key check: The original renderer flags duplicate bindings in red. If you want the same behaviour for custom keys, also hook the duplicate-check loop (inside
OptionsMenu_RenderControls) and extend it to scan your custom array.
Hook Point 4 — Per-Frame Input Polling
Target
InputDevice_PollAndRelease at 0x46EBD0.
Why hook here
This is the only place the game touches DirectInput each frame. By hooking it, your custom keys are sampled at the exact same moment as the built-in keys, with the same device-state semantics (acquire/release, cooperative level, etc.).
What the original does
void InputDevice_PollAndRelease(int self) {
// Keyboard
int didev = *(int*)(self + 0x434);
if (didev) {
IDirectInputDevice8_GetDeviceState(didev, 0x100, self + 0x0C);
}
// ... gamepad polling omitted
}
Your hook strategy
Call the original, then read the same 256-byte buffer (self+0x0C) for your custom DIK codes.
C++ Hook Body
typedef void (__thiscall *tPollAndRelease)(void* pDevice);
tPollAndRelease oPollAndRelease;
void __fastcall hkPollAndRelease(void* pDevice) {
// 1. Let game poll keyboard + gamepads normally
oPollAndRelease(pDevice);
// 2. Read the freshly-polled DI8 buffer for our custom keys
BYTE* dikBuffer = (BYTE*)pDevice + 0x0C;
for (int i = 0; i < g_CustomControlCount; ++i) {
BYTE dik = g_CustomControls[i].dik;
g_CustomControls[i].isDown = (dikBuffer[dik] & 0x80) != 0;
}
}
Do NOT create a separate thread. The game is single-threaded; a background
GetAsyncKeyStateloop would race with the physics update. Always sample input inside the game's own poll hook.
Hook Point 5 — Action Execution
Target
Ball_GetInputForce at 0x46EC30.
What the original does
Reads the 4 directional keys and builds a 2-D force vector:
// Inside Ball_GetInputForce case 1 (keyboard):
if (key_state[left_dik] & 0x80) force_x -= 1.0f;
if (key_state[right_dik] & 0x80) force_x += 1.0f;
if (key_state[up_dik] & 0x80) force_y -= 0.5f;
if (key_state[down_dik] & 0x80) force_y += 1.0f;
// Then writes scale*force to Ball+0x170 (velocity)
Your hook strategy
Call the original function so the 4 built-in directions keep working, then apply your custom action by mutating the Ball struct directly.
C++ Hook Body — Brake Example
typedef void (__thiscall *tBallGetInputForce)(void* pBall, float* outVec);
tBallGetInputForce oBallGetInputForce;
void __fastcall hkBallGetInputForce(void* pBall, float* outVec) {
// 1. Original physics force from 4 directional keys
oBallGetInputForce(pBall, outVec);
// 2. Apply brake if custom key is held
if (g_CustomControls[0].isDown) { // assuming index 0 = Brake
float* vel = (float*)((char*)pBall + 0x170);
// Simple friction model: scale velocity by 0.85 each frame
vel[0] *= 0.85f;
vel[1] *= 0.85f;
vel[2] *= 0.85f;
// Optional: also reduce acceleration so the ball stops trying to speed up
float* accel = (float*)((char*)pBall + 0x2B8);
accel[0] = 0.0f;
accel[1] = 0.0f;
accel[2] = 0.0f;
}
}
Other action ideas
| Action | What to mutate | Notes |
|---|---|---|
| Camera snap | Ball+0xC88 matrix or Scene camera offsets | Rotates view to behind-the-ball |
| Power-up trigger | Call existing power-up function via its address | Find the power-up activate function in FUNCTION_MAP.md |
| Jump | Ball+0x170 Y component | Hard — original physics assumes ground contact; you may need to also hook collision logic |
| Reset ball | Ball+0x164 position | Teleport to last checkpoint (read from Ball+0x2DC) |
Important: Jump is the hardest action because the ball physics does not have an "airborne" state. Simply adding Y-velocity will make the ball float through floors unless you also disable ground-collision snapping while the jump is active. This requires a second hook in the collision resolver.
Hook Point 6 — Registry Persistence
Target
App_SaveAllConfig at 0x4284C0 (save) and the registry-read code inside InputHandler_ctor / App_Initialize_Full (load).
What the original does
Save:
RegKey_WriteDWORD(reg, "2PController1", *(int*)(app + 0xB28));
RegKey_WriteDWORD(reg, "2PController2", *(int*)(app + 0xB2C));
RegKey_WriteDWORD(reg, "2PController3", *(int*)(app + 0xB30));
RegKey_WriteDWORD(reg, "2PController4", *(int*)(app + 0xB34));
Load:
The 4 DWORDs are read during InputHandler construction and stored into the same App+0xB28..0xB34 slots.
Your hook strategy
Option A — Hook save/load directly
Hook App_SaveAllConfig: after the original 4 RegKey_WriteDWORD calls, add your own:
for (int i = 0; i < g_CustomControlCount; ++i) {
char keyName[32];
sprintf(keyName, "CustomControl%d", i+1);
RegKey_WriteDWORD(reg, keyName, g_CustomControls[i].dik);
}
And mirror this on load by hooking the load function (or reading the same keys in your DllMain after the game has initialised).
Option B — External INI (recommended)
Instead of fighting the registry, save your custom bindings to an INI file next to the EXE:
// In DllMain or at first attach
WritePrivateProfileStringA("CustomControls", "Brake",
DIKToString(g_CustomControls[0].dik),
".\hamsterball_mod.ini");
This avoids registry permission issues and makes portable installs easier.
Complete Example: Adding a "Brake" Key
Step-by-step checklist
-
Allocate a new UI row
- Hook
OptionsMenu_RenderControls(0x42E840). - After the original 4 rows, draw
"BRAKE"and the bound key name.
- Hook
-
Extend selection bounds
- Patch the
cmp eax, 3in the menu update function tocmp eax, 4.
- Patch the
-
Capture a key for Brake
- Hook the DIK writer inside the menu update.
- If
index == 4, store DIK intog_CustomControls[0].dik.
-
Poll the Brake key every frame
- Hook
InputDevice_PollAndRelease(0x46EBD0). - After original poll, check
dikBuffer[g_CustomControls[0].dik] & 0x80.
- Hook
-
Apply braking force
- Hook
Ball_GetInputForce(0x46EC30). - If
g_CustomControls[0].isDown, scaleBall+0x170velocity by 0.85.
- Hook
-
Save the binding
- On DLL detach (or in response to an in-game save event), write
g_CustomControls[0].diktohamsterball_mod.ini.
- On DLL detach (or in response to an in-game save event), write
MinHook Skeleton
#include <windows.h>
#include <MinHook.h>
// ---------- global state ----------
struct CustomControl {
const char* name;
BYTE dik;
bool isDown;
};
CustomControl g_CustomControls[8];
int g_CustomControlCount = 0;
// ---------- typedefs ----------
typedef void (__thiscall *tRenderControls)(void*);
typedef void (__thiscall *tPollAndRelease)(void*);
typedef void (__thiscall *tBallGetInputForce)(void*, float*);
tRenderControls oRenderControls;
tPollAndRelease oPollAndRelease;
tBallGetInputForce oBallGetInputForce;
// ---------- hook bodies ----------
void __fastcall hkRenderControls(void* pMenu) { /* ... see section 5 ... */ }
void __fastcall hkPollAndRelease(void* pDev) { /* ... see section 7 ... */ }
void __fastcall hkBallGetInputForce(void* pBall, float* out)
{ /* ... see section 8 ... */ }
// ---------- dll entry ----------
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) {
if (reason == DLL_PROCESS_ATTACH) {
MH_Initialize();
MH_CreateHook((LPVOID)0x42E840, hkRenderControls,
(LPVOID*)&oRenderControls);
MH_CreateHook((LPVOID)0x46EBD0, hkPollAndRelease,
(LPVOID*)&oPollAndRelease);
MH_CreateHook((LPVOID)0x46EC30, hkBallGetInputForce,
(LPVOID*)&oBallGetInputForce);
// Enable all hooks
MH_EnableHook(MH_ALL_HOOKS);
}
return TRUE;
}
Compile with: MSVC or MinGW, linking against
minhook.lib(orlibminhook.a). Inject with your preferred loader (e.g.dinput8.dllproxy,xinput1_3.dllproxy, or an external injector like Process Hacker).
Address Quick Reference
| Symbol | Address | Notes |
|---|---|---|
OptionsMenu_RenderControls |
0x42E840 | Draws 4 control rows; hook post-render |
OptionsMenu_ctor |
0x442CE0 | Builds menu; useful for finding vtable |
UIList_AddItem |
0x4497F0 | Menu item constructor |
InputDevice_PollAndRelease |
0x46EBD0 | Polls DI8; hook for custom key sampling |
InputDevice_ctor |
0x466620 | Sets default DIK codes |
Ball_GetInputForce |
0x46EC30 | Physics force builder; hook for custom actions |
Input_IsKeyDown |
0x46E0B0 | Generic key-state check |
App_SaveAllConfig |
0x4284C0 | Registry save; hook for custom persistence |
App_Initialize_Full |
0x429530 | Game init; creates InputHandler+devices |
DirectInput8Create |
0x47C7F0 | Only DI8 import |
InputDevice+0x0C |
— | 256-byte DI8 keyboard state buffer |
InputDevice+0x50C |
— | key_left DIK code |
InputDevice+0x510 |
— | key_right DIK code |
InputDevice+0x514 |
— | key_up DIK code |
InputDevice+0x518 |
— | key_down DIK code |
Ball+0x170 |
— | velocity (Vec3) |
Ball+0x2B8 |
— | acceleration (Vec3) |
Ball+0x164 |
— | position (Vec3) |
Ball+0x2DC |
— | last_checkpoint index |
App+0xB28 |
— | CONTROL1 DWORD |
App+0xB2C |
— | CONTROL2 DWORD |
App+0xB30 |
— | CONTROL3 DWORD |
App+0xB34 |
— | CONTROL4 DWORD |
Common Pitfalls
-
Do not resize the InputDevice struct. It is 0x91C bytes with hardcoded offsets throughout the EXE. Store custom keys in your own DLL globals.
-
Do not use a background thread for input. The game is single-threaded; race with physics will corrupt
Ball+0x170. -
The original remap UI does not exist for rows 5+. You must draw them yourself; copy the same font/colour the game uses for rows 1-4.
-
Duplicate-key detection is hardcoded to 4 slots. If you want red-warning for conflicting custom keys, extend the duplicate-check logic in your
OptionsMenu_RenderControlshook. -
Registry keys beyond CONTROL4 do not exist in vanilla. Use an external INI or add keys under a different sub-key (e.g.
HKCU\Software\HamsterballMod) to avoid clobbering the game's save format. -
Joystick mode complicates things. If a player binds your custom action to a joystick button, you must also poll the gamepad state array (at
InputHandler+0x40gamepad array). The DI8 keyboard buffer will not contain joystick buttons.
Version History
| Date | Change |
|---|---|
| 2026-06-02 | Initial document — 6 hook points, brake example, MinHook skeleton |
🔗 Related Documents
Agent Knowledge Package
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/INDEX.md)
Agent Knowledge Package - Hamsterball Reverse Engineering
This folder is a self-contained bootstrapping guide for any future agent that needs to reproduce or continue the Hamsterball RE work.
Start here and read the numbered sections in order. Each file is designed to be actionable on its own.
| # | File | Purpose |
|---|---|---|
| 1 | 01-BOOTSTRAP.md | Set up workspace, acquire assets, run first binary analysis |
| 2 | 02-GHIDRA-SETUP.md | Import binary, start GhidraMCP headless, verify server |
| 3 | 03-RENAME-RESTORE.md | Import the 975+ function renames from FUNCTION_MAP.md |
| 4 | 04-DECOMP-WORKFLOW.md | How to decompile, clean, and verify code faithfully |
| 5 | 05-STRUCT-VERIFICATION.md | Methodology for confirming struct offsets |
| 6 | 06-MODDING-PATTERNS.md | Common game RE patterns (dead code, vtables, level gating) |
| 7 | 07-REIMPL-LESSONS.md | All lessons learned from reimplementation attempts |
| 8 | 08-TROUBLESHOOTING.md | Known failure modes and workarounds |
| 9 | scripts/ | Reusable Python helpers |
| 10 | templates/ | Copy-paste decompile/verify script templates |
External Authorities
- The repo's
docs/FUNCTION_MAP.mdis the source of truth for named functions. - The repo's
analysis/ghidra/structs/*.hcontain C struct definitions exported from Ghidra. docs/STRUCTS_AND_TYPES.mdis the human-facing struct reference (but verify offsets with raw decompilation before trusting).
First Command Checklist
cd ~/hamsterball-re
file originals/installed/extracted/Hamsterball.exe
python3 docs/agent-knowledge/scripts/check_server.py
🔗 Related Documents
ai_8ball_fix
ai_8ball_fix
Fixes 8-ball AI so it moves in races
Files
ai_8ball_full_v3.c— C source codebass.dll— Compiled DLL (PE32 i386)
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dll(or renamed DLL) into the game folder - Launch Hamsterball
🔗 Related Documents
App::Initialize Function Decom
App::Initialize Function Decomposition (0x00429530)
Function Signature
void App::Initialize(App* this, int hInstance, int nShowCmd);
// Called from WinMain at 0x4278EF
// Object pointer (this) in ECX = 0x4FD680 (global App instance)
// Stack args: hInstance (arg_4h), nShowCmd (arg_ch)
// Debug string stored at this+0x208 after each step
Initialization Sequence (CONFIRMED from r2 disassembly)
Step 1: App::Initialize(1) at 0x4D2B24
- Stores debug string "[Initialize(1)]" at this+0x208
- Calls fcn.0046BB40 (base init) with hInstance and nShowCmd
- Gets this->graphics (this+0x174)
- Sets this->graphics->initialized = true (this->graphics+0x7D1)
Step 3: App::Initialize(3) at 0x4D2B08
- Loads blank cursor: LoadCursorA(NULL, "BLANKCURSOR")
- Stores cursor handle at this+0x240
Step 4: App::Initialize(4) at 0x4D2AF8
- Calls virtual function at this->+vtable+0x8C(140) with params 800, 600
- This is likely Graphics::SetDisplayMode(800, 600)
- this+0x240 = cursor handle
- Push 0x258 (600) + Push 0x320 (800)
Step 5: App::Initialize(5) at 0x4D2AE8
- Checks this->graphics (this+0x174)
- If NULL: sets debug string "** No Graphics **" at 0x4D2AD4
- Checks this->graphics->d3d_device (this->graphics+0x154)
- If NULL: sets debug string "** No Graphics Device **" at 0x4D2AB8
- If graphics OK: calls d3d_device->vtable+0xC8(200) with params
- This is likely IDirect3DDevice8::SetRenderState(D3DRS_AMBIENT, ...)
- Or IDirect3DDevice8::SetTransform(D3DTS_WORLD, ...)
- Pass 2 or 3 for lighting mode
Step 6: App::Initialize(6) at 0x4D2A9C
- Loads shadow texture: fcn.00455C50(this->graphics, "shadow.png", 1)
- fcn.00455C50 is likely Graphics::LoadTexture()
- Result stored at this+0x278
Step 7: App::Initialize(7) at 0x4D2A7C
- Loads music: fcn.004743F0("music\music.mo3")
- fcn.004743F0 is Music::Load("music\music.mo3")
- Result stored at this->music (this+0x534)
- this->music_channel1 (this+0x53C) = 0
- this->music_channel2 (this+0x538) = 0
Step 8: App::Initialize(8) at 0x4D2A6C
- If music loaded successfully (this->music != 0):
- Loads Jukebox: fcn.0046A4D0(this->music, "jukebox.xml")
- Calls fcn.0046A3C0(this->music) -> this->music_channel1 (this+0x53C)
- Calls fcn.0046A3C0(this->music) -> this->music_channel2 (this+0x538)
Step 9: App::Initialize(9) at 0x4D2A50
- Continues music initialization
Step 10: App::Initialize(10) at 0x4D2A40
- Continues music initialization
Step 11: App::Initialize(11) at 0x4D2A30
- Music setup continues
Step 12: App::Initialize(12) at 0x4D2A20
- Calls fcn.00472EC0(this->config) — likely Config::Load()
Step 13: App::Initialize(13) at 0x4D2A10
- Reads "PlayCount" from config: fcn.00473170(this->config, "PlayCount")
- Check if key exists
- If exists: read PlayCount value -> this+0x914
- This is for shareware trial mode
Continues with more initialization...
App Object Structure (INFERRED from offsets)
struct App {
void* vtable; // +0x00
HWND hwnd; // +0x04
/* ... */
void* unknown_08; // +0x08 through +0x50
/* ... */
Graphics* graphics; // +0x174
/* ... */
char debug_str[256]; // +0x208 (stores init step name)
/* ... */
HCURSOR blank_cursor; // +0x240
/* ... */
int display_width; // +0x254 (800)
int display_height; // +0x258 (600)
/* ... */
void* shadow_texture; // +0x278
/* ... */
MusicPlayer* music; // +0x534
int music_channel1; // +0x53C
int music_channel2; // +0x538
/* ... */
int play_count; // +0x914
/* ... */
int running; // +0x159 (set to 1 to quit)
int target_fps; // +0x170 (used for Sleep calcs)
int frame_count; // +0x164 (for FPS calculation)
int last_tick; // +0x168 (for delta time)
Config* config; // +0x54
};
Key Sub-Functions
| Address | Likely Name | Notes |
|---|---|---|
| 0x0046BB40 | App::BaseInit() | Called with hInstance, nShowCmd |
| 0x00455C50 | Graphics::LoadTexture() | Loads shadow.png and textures |
| 0x004743F0 | Music::Load() | Loads music.mo3 |
| 0x0046A4D0 | Music::LoadJukebox() | Loads jukebox.xml |
| 0x0046A3C0 | Music::CreateChannel() | Creates playback channels |
| 0x00472EC0 | Config::Load() | Loads configuration |
| 0x00473170 | Config::HasKey() | Checks if config key exists |
| 0x00473080 | Config::GetInt() | Gets integer config value |
| 0x00455380 | Graphics::Initialize() | Creates D3D8 device |
| 0x0046BD80 | app::run() | Game loop (PeekMessageA based) |
| 0x0046BA10 | App::Shutdown() | Sets this->running = 1, calls vtable+8 |
🔗 Related Documents
App::Run
App::Run - Game Loop Decomposition (0x0046BD80)
Function Signature
void App::Run(App* this); // ECX = App* (0x4FD680)
// Called from WinMain at 0x4278F9
Key App Structure Offsets
// this+0x159: running flag (1 = quit, 0 = running)
// this+0x15A: active flag (1 = game active, 0 = paused/minimized)
// this+0x158: minimized flag
// this+0x164: last_tick (GetTickCount value)
// this+0x168: frame_time (1000 / this->0x170 = ms per frame)
// this+0x170: target_fps (e.g., 60)
// this+0x174: graphics pointer
// this+0x18C: frame counter (for FPS tracking)
// this+0x194: frame_count (for display)
// this+0x1AC: show_fps flag
Pseudocode
void App::Run(App* this) {
// Initialize timing
int frame_time = 1000 / this->target_fps; // this+0x170 = FPS target
HMODULE kernel32 = GetTickCount;
int last_tick = GetTickCount();
this->last_tick = last_tick;
if (this->running) goto shutdown; // this+0x159
push edi;
// === MAIN GAME LOOP ===
while (1) {
Sleep(0); // Yield to other processes
// Calculate frame timing
int tick_interval = 1000 / this->fps_counter; // this+0x16C
this->debug_str = "Background"; // this+0x208
this->frame_time_ms = tick_interval; // this+0x168
int current_tick = GetTickCount();
// FPS counting
if (current_tick > global_time_limit) { // 0x5341E4
if (this->show_fps) { // this+0x1AC
sprintf(this->fps_buffer, "%d", this->frame_count); // this+0x198
}
this->frame_count = 0; // this+0x194
global_time_limit = GetTickCount() + 1000;
}
// === MESSAGE PUMP ===
MSG msg;
while (PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE)) {
if (this->running) goto shutdown;
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
if (this->running) goto shutdown;
// === TIMING CHECK ===
// Only update/draw if enough time has passed
int now = GetTickCount();
int elapsed = now - this->last_tick;
int min_interval = this->frame_time_ms - 5;
if (elapsed < min_interval) {
// Too early - skip frame or count skipped frames
this->skip_count++; // var_10h
if (this->skip_count >= 10) {
// Too many skipped frames - force update
goto force_update;
}
// === UPDATE FRAME ===
this->frame_counter++; // this+0x18C
Graphics::BeginScene(this->graphics); // fcn.00453B50
this->vtable->Update(this); // vtable+0x20
this->last_tick += this->frame_time_ms;
// Cap delta time at 1000ms
if (now - this->last_tick > 1000) {
this->last_tick = now; // Reset if too far behind
}
this->rendered_frames++;
} else {
// === RENDER FRAME ===
this->debug_str = "Draw"; // this+0x208
if (this->graphics && !this->paused && !this->minimized) {
this->frame_count++; // this+0x194
Graphics::BeginScene(this->graphics); // fcn.00453B50
this->vtable->Update(this); // vtable+0x20
this->vtable->Draw(this); // vtable+0x24
this->vtable->Flip(this); // vtable+0x28
// Present (Graphics::Present or D3D Present)
Graphics::Present(this->graphics, 1); // fcn.00455A90
}
this->last_tick = GetTickCount();
}
}
shutdown:
// Cleanup
destructor(var_34);
return;
}
Virtual Table Methods
The game uses a vtable at this->vtable with key methods:
vtable+0x08: Shutdown (from App::Shutdown at 0x46BA10)vtable+0x20(32): Update — Game logic per framevtable+0x24(36): Draw — Rendering per framevtable+0x28(40): Flip — Buffer swap / presentvtable+0x2C(44): Additional render stepvtable+0x8C(140): SetDisplayMode (called during init with 800, 600)
Key Sub-Functions
| Address | Name | Description |
|---|---|---|
| 0x453B50 | Graphics::BeginScene | Prepares scene for rendering |
| 0x455A90 | Graphics::Present | Presents frame buffer |
| 0x457AD0 | (constructor?) | Called at function start |
| 0x457A40 | (destructor?) | Called at function end |
| 0x4BAE43 | sprintf | Used for FPS display |
Timing Model
- Target FPS stored at
this+0x170 - Frame time = 1000 / FPS (stored at
this+0x168) - Skip frames if elapsed < frame_time - 5ms
- Force update after 10 consecutive skips (cap at ~10 physics updates per render)
- Global time limit at 0x5341E4 for FPS counter reset
Frame Rate Control
1000 / this->fpsgives ms per frame (typically 16ms for 60fps)- Skip tolerance:
frame_time - 5ms (e.g., 11ms at 60fps) - Maximum 10 skip updates before forcing a render
- Delta time capped at 1000ms to prevent huge physics jumps
🔗 Related Documents
Arena Hazard System (RumbleBoa
Arena Hazard System (ArenaBoard Objects)
The ArenaBoard arenas contain interactive hazards created by the master factory
function CreateExpertLevelObjects (0x40E250). Despite its name, this function creates
ALL arena object types, not just sawblades.
Object Factory: CreateExpertLevelObjects (0x40E250)
Dispatches on name prefix (case-insensitive __strnicmp). Only creates objects
when App+0x23C != 0 (Frenzied difficulty check for tournament-only objects).
Object Type Table
| Name Prefix | Class | Size | Ctor Address | Storage |
|---|---|---|---|---|
| "BONK" | Bonk | 0x1200 | Bonk_ctor | this+0x2578 list, this+0x436C ptr |
| "SAW" (3 chars) | TowerLevel | 0x1188 | TowerLevel_Ctor | this+0x2578 list |
| "SAWBLADE" | Sawblade_Level | 0x111C | Sawblade_Level_Ctor | this+0x2578 list, this+0x4370/4374 |
| "BRIDGE" | Spinner_Level | 0x10FC | Spinner_Level_ctor | this+0x4380/4798 lists |
| "JUDGE" | Gear_Level | 0x1100 | Gear_Level_ctor | this+0x4BBC list |
| "BELL" | Tipper_Level | 0x10E8 | Tipper_Level_Ctor | this+0x2578 list, this+0x4FD4 ptr |
Name Suffix Modifiers
Objects can be named with suffix modifiers that change behavior:
| Suffix | Object Type | Effect | Offset Modified |
|---|---|---|---|
| "SLOW" | TowerLevel | Slow speed mode | obj+0x43B = 1 |
| "SUPER" | TowerLevel | Super mode | obj+0x10ED = 1 |
| "UP" | TowerLevel | Initialize sound channels | Sound_InitChannels(1) |
| "1" | Sawblade | Saw #1, break sound 1 | this+0x4370, Sawblade_SetBreakSound(1) |
| "2" | Sawblade | Saw #2, break sound 2 | this+0x4374, Sawblade_SetBreakSound(2) |
| "1" | Bridge | Add to bridge list 1 | this+0x4380 list |
| "2" | Bridge | Add to bridge list 2 | this+0x4798 list |
| "NEG" | Bridge | Reverse rotation (speed=-1.0) | obj[0x43E] = 0xBF800000 |
Sawblade System
Sawblade_Level Structure
| Offset | Type | Field | Default |
|---|---|---|---|
| +0x10D0 | int | board_ptr | Parent ArenaBoard |
| +0x10D4 | Vec3 | position | Creation position |
| +0x10E0 | Vec3 | base_position | Copy of position |
| +0x10EC | int | state | 0 (inactive) |
| +0x10F0 | float | angle | RNG_Rand(0x168) random start angle |
| +0x10F4 | int | tick_count | 0 |
| +0x10FC | int | anim_frame | 0 |
| +0x110C | byte | is_alert | 0 |
| +0x110D | byte | alert_enabled | 1 (can show alert) |
| +0x1110 | float | alert_timer | 500.0 (0x43FA0000) |
| +0x1114 | byte | is_active | 0 |
| +0x1118 | int | break_sound_id | 0 |
Sawblade Lifecycle
-
Alert Phase:
Saw_AlertActivate(0x434770)- Sets
alert_enabled = 0(one-shot) - Plays warning sound at saw position (3D positioned)
- Triggered by "E:ALERTSAW1"/"E:ALERTSAW2" collision events
- Sets
-
Active Phase:
Saw_Activate(0x434A50)- Sets
is_active = 1 - Plays break/activate sound at position
- Triggered by "E:ACTIVATESAW1"/"E:ACTIVATESAW2" collision events
- Sets
-
Render:
Sawblade_Render(0x4347E0)- Draws the spinning sawblade mesh
-
Sound:
Sawblade_SetBreakSound(0x434AB0)- Sets which sound ID to play on break (1 or 2)
- Two saw slots per arena (offset 0x4370 and 0x4374)
Sawblade Sound References
| App Offset | Sound Type |
|---|---|
| +0x4BC | Alert/ping sound |
| +0x4C0 | Break/activate sound |
Spinner (Bridge) System
Spinner_Level (0x10FC bytes)
Rotating bridge platforms. Created with "BRIDGE" prefix.
Two independent bridge lists allow separate control:
- Bridge list 1:
this+0x4380(triggered by one event) - Bridge list 2:
this+0x4798(triggered by another event)
The "NEG" suffix reverses rotation direction (speed = -1.0).
Judge (Gear) System
Gear_Level (0x1100 bytes)
Rotating gear hazards named "JUDGE" prefix. Stored in a judge list at
this+0x4BBC. Triggered by "E:ALERTJUDGES" collision event which calls
Judge_Reset for all gears.
Bell (Tipper) System
Tipper_Level (0x10E8 bytes)
Tilting/bell hazards named "BELL" prefix. Only one bell stored directly at
this+0x4FD4. Also added to the general object list at this+0x2578.
Triggered by "E:BELL" collision events — activates bell and awards 500 bonus
time if not already playing.
Bonk (Hammer) System
Bonk (0x1200 bytes)
Hammer obstacles named "BONK" prefix. Only created on Frenzied difficulty
(App+0x23C != 0). Stored at this+0x436C (single hammer) and this+0x2578 list.
Hammer_ChaseStart (0x438BB0) starts the chase sequence for tournament mode.
The collision event "E:CALLHAMMER" creates a bonk popup via CreateBonkPopup.
TowerLevel (Spinning Tower) System
TowerLevel (0x1188 bytes)
Spinning tower obstacles with "SAW" (3-char) prefix. Not the same as sawblades.
Has speed modifiers via name suffixes:
- "SLOW": Reduced rotation speed (obj+0x43B=1)
- "SUPER": Enhanced mode (obj+0x10ED=1)
- "UP": Initialize additional sound channel
Object Storage Map (ArenaBoard offsets)
| Offset | Type | Purpose |
|---|---|---|
| +0x2578 | AthenaList | All arena objects (master list) |
| +0x436C | void* | Bonk/Hammer pointer |
| +0x4370 | void* | Sawblade #1 pointer |
| +0x4374 | void* | Sawblade #2 pointer |
| +0x4380 | AthenaList | Bridge list #1 |
| +0x4798 | AthenaList | Bridge list #2 |
| +0x4BBC | AthenaList | Judge/Gear list |
| +0x4FD4 | void* | Bell/Tipper pointer |
Collision-to-Action Dispatch
See COLLISION_SYSTEM.md for the full ExpertCollisionEvents dispatch table.
Summary of arena collision events:
| Event Name | Action | Object |
|---|---|---|
| E:ALERTSAW1 | Saw_AlertActivate | Saw #1 warning ping |
| E:ALERTSAW2 | Saw_AlertActivate | Saw #2 warning ping |
| E:ACTIVATESAW1 | Saw_Activate | Saw #1 full activate |
| E:ACTIVATESAW2 | Saw_Activate | Saw #2 full activate |
| E:ALERTJUDGES | Judge_Reset | All gears warning |
| E:SCORE<n> | ScoreDisplay_SetTime | Set time from suffix |
| E:JUMP | Jump pad activation | +vert_vel, sound |
| E:BELL<suffix> | Bell_Activate | +500 bonus time |
| E:CALLHAMMER | CreateBonkPopup | Tournament bonk |
| E:HAMMERCHASE | Hammer_ChaseStart | Tournament chase |
App Difficulty Values
From DifficultyMenu_ctor and Tournament_AdvanceRace:
| Difficulty | Value | Time Bonus | Speed |
|---|---|---|---|
| PIPSQUEAK | "EASY" | +1000ms | Slower hazards |
| NORMAL | "NORMAL" | Standard | Normal speed |
| FRENZIED! | "HARD" | +500ms | Faster + BONK/JUDGE objects |
Frenzied mode enables creation of BONK and JUDGE objects (App+0x23C check)
and reduces time bonus from +1000 to +500.
Related Functions
| Address | Name | Purpose |
|---|---|---|
| 0x40E250 | CreateExpertLevelObjects | Master arena object factory |
| 0x434660 | Sawblade_Level_Ctor | Sawblade constructor |
| 0x434A50 | Saw_Activate | Full activation |
| 0x434770 | Saw_AlertActivate | Warning phase |
| 0x4347E0 | Sawblade_Render | Render spinning blade |
| 0x434640 | Sawblade_SetActive | Set active state |
| 0x434AB0 | Sawblade_SetBreakSound | Set break sound ID |
| 0x438BB0 | Hammer_ChaseStart | Start hammer chase |
| 0x40E2A4 | Bonk_ctor | Hammer/bonk constructor |
| 0x40E340 | TowerLevel_Ctor | Spinning tower constructor |
| 0x40E4C0 | Spinner_Level_ctor | Bridge/spinner constructor |
| 0x40E5AC | Gear_Level_ctor | Judge/gear constructor |
| 0x40E61F | Tipper_Level_Ctor | Bell/tipper constructor |
🔗 Related Documents
Arena Scoring System
Hamsterball Arena Scoring System — Modder's Reference
Document version: 2026-06-13
Based on: Hamsterball.exe decompilation via Ghidra + GhidraMCP
Target audience: Modders, trainers, reverse engineers
Table of Contents
- Overview
- Data Structures
- Score Lifecycle
- Timer System
- Win Condition Logic
- How to Edit Scores
- Function Reference
- Offsets Quick Reference
Overview
Hamsterball's multiplayer Arena Mode (ArenaBoard) uses a per-player scoring system where:
- Each player has a score value tracked inside the
ArenaBoardobject - A countdown timer limits round duration (default: 60 seconds)
- When time expires, the player with the highest score wins
- If multiple players tie for highest score, a tie-breaker round begins
- Falling off the board typically awards points to surviving players
The scoring system is completely separate from the single-player race timer system. Arena scores are stored in the ArenaBoard struct, while race times are stored in the App struct.
Data Structures
ArenaBoard Scoring Fields
The ArenaBoard inherits from Board → Gadget → Scene. Within the ArenaBoard, these offsets control scoring:
| Offset | Type | Field | Description |
|---|---|---|---|
+0x11ED |
int32 |
p1_score |
Player 1 current score |
+0x11EE |
int32 |
p2_score |
Player 2 current score |
+0x11EF |
int32 |
p3_score |
Player 3 current score |
+0x11F0 |
int32 |
p4_score |
Player 4 current score |
+0x47AC |
int32 |
time_limit |
Countdown duration in ticks (default: 6000 ≈ 60s) |
+0x47B0 |
byte |
timer_started |
1 = countdown is running |
+0x47C4 |
byte |
tie_breaker |
1 = tie-breaker active |
+0x47C5 |
byte |
game_over |
1 = round ended, results showing |
+0x11EB |
int32 |
countdown |
Frames remaining until check |
+0x11EC |
byte |
timer_toggle |
Internal timer flip-flop |
+0x11F1 |
byte |
results_shown |
1 = RaceResultsMenu already created |
Verified in:
ArenaBoard_Update(0x421FE0) andArenaBoard_Render(0x421910)
App Player Slots
Each player's tournament/race data lives in the App struct with a stride of 0xA0:
| Player | Active Flag | Score/Time | Extra Time | Race Index | Level Name String |
|---|---|---|---|---|---|
| P1 | +0x5D8 |
+0x5E8 |
+0x5EC |
+0x60C |
+0x610 |
| P2 | +0x678 |
+0x688 |
+0x68C |
+0x6CC |
+0x6D0 |
| P3 | +0x718 |
+0x728 |
+0x72C |
+0x76C |
+0x770 |
| P4 | +0x7B8 |
+0x7C8 |
+0x7CC |
+0x80C |
+0x810 |
In arena mode, the +0x5E8 slot is repurposed as a score accumulator rather than a race time. The ArenaBoard copies scores into these slots at round end.
Verified in:
PlayerProfile_ctor(0x426F30),Tournament_AdvanceRace(0x427080)
ScoreObject Structure
Pop-up score notifications (like "EXTRA TIME:") use this struct:
| Offset | Type | Field |
|---|---|---|
+0x00 |
vtable* |
ScoreObject vtable = 0x4D6C70 |
+0x04 |
App* |
app pointer |
+0x08 |
void* |
player data pointer |
+0x0C |
App* |
app (duplicate) |
+0x10 |
int32 |
display value |
+0x14 |
int32 |
lifetime timer |
+0x18 |
byte |
active flag |
+0x1C |
float |
x position |
+0x20 |
float |
y position |
+0x24 |
float |
scale |
+0x2C |
char* |
label string (e.g., "EXTRA TIME:") |
Size: 0x30 bytes
Ctor:ScoreObject_ctorat 0x44BE80
Score Lifecycle
1. Score Initialization
When a ArenaBoard arena is constructed (ArenaBoard_ctor, 0x4217B0):
- All four player scores (
+0x11ED..+0x11F0) are initialized to 0 time_limit(+0x47AC) is set to 6000 (60 seconds)timer_started(+0x47B0) is 0 initially
2. Score Increment Events
During gameplay, scores increase through these mechanisms:
A. Ball Falls Off Board
When a ball falls off the arena platform:
Ball_Shrink(0x402200) triggers — sets airborne flag, plays soundBall_FallUpdate(0x408830) runs physics until ball hits void- The ArenaBoard detects the fallen player and awards +10 points to all surviving players via
Rotator_AddBall(0x43B6F0)
B. Bell Collision Bonus
When a ball hits an E:BELL event plane:
Bell_Activateplays the bell sound- If not in a race/demo: +500 bonus time added to the player's timer (
App+0x5EC) - A
ScoreObjectpopup with"EXTRA TIME:"text is created and appended to the scene score list (ArenaBoard+0x8B8)
Source:
ExpertCollisionEvents(0x40E6A0), lines 90-108
C. Direct ScoreObject Creation
Any code can create a score popup:
void* scoreObj = operator_new(0x30);
ScoreObject_ctor(scoreObj, app, player_data_ptr, "MY LABEL:");
Timer_Decrement(scoreObj);
AthenaList_Append(rumbleBoard + 0x8B8, scoreObj);
3. Score Storage
Rotator_AddBall (0x43B6F0) manages a linked list of score entries:
// Each entry in the list is 8 bytes:
struct ScoreEntry {
int32_t player_id; // +0x00
int32_t score; // +0x04 (always set to 10)
};
- If
player_idalready exists in the list: update its score to 10 - If new: allocate 8 bytes and append to
ScoreObject+0x10F0list
Note: This function always sets score = 10. It does not accumulate.
Timer System
Countdown Timer
The ArenaBoard uses a tick-based countdown (not real-time seconds):
| Value | Meaning |
|---|---|
time_limit = 6000 |
~60 seconds at 100 ticks/sec |
time_limit < 1100 |
Final 11 seconds — HUD flashes red |
time_limit < 600 |
Final 6 seconds — HUD flashes blue |
time_limit == 0 |
Round over — trigger win calculation |
Timer Display
ArenaBoard_Render (0x421910) draws the timer at screen center:
- Background quad at
(screen_center - 88, 10, 180, 105) - Main time string in
MM:SSformat (large font) - Decimal fraction
".N"(tenths of a second) - Color shifts to red/blue during final seconds
Tie-Breaker Display
When tie_breaker flag (+0x47C4) is set:
"TIE BREAKER!"text renders at screen position(400, 40)- Timer continues counting down
- First player to score wins
Win Condition Logic
Round End Trigger
ArenaBoard_Update (0x421FE0) checks every frame:
// Pseudocode from decompilation:
if (timer_started && countdown_expired) {
// Find maximum score among active players
max_score = MAX(p1_score, p2_score, p3_score, p4_score);
// Count how many players have max_score
tie_count = 0;
for each active player:
if (player_score == max_score) tie_count++;
if (tie_count >= 2) {
// TIE — enable tie-breaker mode
tie_breaker = 1;
timer_started = 0; // Actually continues in tie-breaker
} else {
// WINNER — show results
game_over = 0;
winner_idx = player_with_max_score;
CreateRaceResultsMenu(rumbleBoard, winner_idx);
results_shown = 1;
}
}
Important Logic Details
From the decompilation at 0x421FE0:
- Inactive players are skipped — checks
App+0x5D7,+0x677,+0x717,+0x7B7(hidden flags) - Tie requires ≥2 players tied — if only 1 player has max, they win immediately
- RaceResultsMenu is created once — protected by
results_shownflag (+0x11F1) - Audio cue — plays
"Game Over"music when results appear
How to Edit Scores
Method 1: Direct Memory Patching (Recommended for Trainers)
The simplest way to manipulate scores is writing directly to the ArenaBoard fields:
// Get App pointer from global
App* app = *(App**)0x004FD680;
// Get current ArenaBoard from active scene
// The ArenaBoard is the current Scene object in arena mode
Scene* scene = app->currentScene; // App+0x178
// Verify we're in arena mode (ArenaBoard vtable = 0x4D1358)
if (*(uint32_t*)scene == 0x004D1358) {
// Edit scores directly
*(int32_t*)((char*)scene + 0x11ED) = 999; // P1 score = 999
*(int32_t*)((char*)scene + 0x11EE) = 0; // P2 score = 0
*(int32_t*)((char*)scene + 0x11EF) = 0; // P3 score = 0
*(int32_t*)((char*)scene + 0x11F0) = 0; // P4 score = 0
}
Method 2: Hook Rotator_AddBall
Intercept score changes as they happen:
// Original: 0x43B6F0 __thiscall Rotator_AddBall(void* this, int player_id)
void __fastcall Hook_Rotator_AddBall(void* scoreObj, int player_id) {
// Force P1 to always get +100 instead of +10
if (player_id == 0) {
// Write directly to the score entry after original processes
Original_Rotator_AddBall(scoreObj, player_id);
// Now patch the stored value from 10 to 100
// (Requires walking the list at scoreObj+0x10F0)
return;
}
Original_Rotator_AddBall(scoreObj, player_id);
}
Method 3: Freeze Timer
Prevent round from ending:
// In your per-frame hook (e.g., App_FrameUpdate at 0x46C170):
Scene* scene = app->currentScene;
if (*(uint32_t*)scene == 0x004D1358) { // ArenaBoard vtable
*(int32_t*)((char*)scene + 0x47AC) = 6000; // Reset time_limit to max
*(int32_t*)((char*)scene + 0x11EB) = 100; // Reset countdown
}
Method 4: Force Win (Single Player Arena)
End the round immediately with P1 as winner:
Scene* scene = app->currentScene;
if (*(uint32_t*)scene == 0x004D1358) {
// Set P1 to winning score, others to 0
*(int32_t*)((char*)scene + 0x11ED) = 100;
*(int32_t*)((char*)scene + 0x11EE) = 0;
*(int32_t*)((char*)scene + 0x11EF) = 0;
*(int32_t*)((char*)scene + 0x11F0) = 0;
// Force timer to expire
*(int32_t*)((char*)scene + 0x47AC) = 0;
*(int32_t*)((char*)scene + 0x11EB) = 0;
*(char*)((char*)scene + 0x47B0) = 1; // timer_started = true
}
Method 5: Edit App Tournament Data
For tournament mode, scores are copied to App struct at round end:
// App player data offsets (P1 example):
// +0x5E8 = current_time (in arena: score)
// +0x5EC = extra_time (bonus accumulates here)
*(int32_t*)((char*)app + 0x5E8) = 0; // P1 score = 0
*(int32_t*)((char*)app + 0x688) = 99999; // P2 score = 99999 (win)
Function Reference
Core Scoring Functions
| Address | Name | Signature | Description |
|---|---|---|---|
0x43B6F0 |
Rotator_AddBall |
__thiscall (void* this, int player_id) |
Set/add score entry for a player |
0x44BE80 |
ScoreObject_ctor |
__thiscall (void* this, int app, int player_data, char* label) |
Create score popup object |
0x434C80 |
ScoreDisplay_SetTime |
__thiscall (void* this, int time) |
Set displayed time string |
0x421FE0 |
ArenaBoard_Update |
__fastcall (int* this) |
Per-frame update + win check |
0x421910 |
ArenaBoard_Render |
__thiscall (void* this, undefined4) |
Draw HUD/timer |
0x4217B0 |
ArenaBoard_ctor |
__thiscall (void* this, int app) |
Initialize arena with time_limit=6000 |
Ball / Death Functions
| Address | Name | Description |
|---|---|---|
0x402200 |
Ball_Shrink |
odd race E:SHRINK collision — shrink ball |
0x408830 |
Ball_FallUpdate |
Physics update while falling |
0x405190 |
Ball_FindClosestRespawnPoint |
Find respawn after fall |
Arena Collision
| Address | Name | Description |
|---|---|---|
0x40E6A0 |
ExpertCollisionEvents |
Master collision dispatcher |
0x434E20 |
Bell_Activate |
Bell hit → bonus time |
0x438BB0 |
Hammer_ChaseStart |
Start hammer chase sequence |
0x434A50 |
Saw_Activate |
Activate saw blade |
0x434770 |
Saw_AlertActivate |
Saw warning ping |
Results / Menu
| Address | Name | Description |
|---|---|---|
0x44CB10 |
RaceResultsMenu_ctor |
Create race results overlay |
0x44C260 |
RaceResultPopup_ctor |
"TIME'S UP!" / "OUT OF TIME!" popup |
0x44C4B0 |
RaceResults_Tick |
Animate results screen |
0x451DF0 |
TourneyMenu_TickWithRank |
Tournament rank calculation |
Offsets Quick Reference
ArenaBoard Scoring Offsets
+0x11ED int32 p1_score
+0x11EE int32 p2_score
+0x11EF int32 p3_score
+0x11F0 int32 p4_score
+0x11EB int32 countdown_timer
+0x11EC byte timer_toggle
+0x11F1 byte results_shown
+0x47AC int32 time_limit (default 6000)
+0x47B0 byte timer_started
+0x47C4 byte tie_breaker
+0x47C5 byte game_over
+0x8B8 void* score_object_list (AthenaList)
App Player Data Offsets
// Player 1
+0x5D8 byte p1_active
+0x5E4 int32 p1_score_aux
+0x5E8 int32 p1_current_time
+0x5EC int32 p1_extra_time
+0x60C int32 p1_race_index
+0x610 char* p1_level_name
// Player 2 (+= 0xA0)
+0x678 byte p2_active
+0x688 int32 p2_current_time
+0x68C int32 p2_extra_time
// Player 3 (+= 0x140)
+0x718 byte p3_active
+0x728 int32 p3_current_time
+0x72C int32 p3_extra_time
// Player 4 (+= 0x1E0)
+0x7B8 byte p4_active
+0x7C8 int32 p4_current_time
+0x7CC int32 p4_extra_time
Vtables for Type Checking
0x004D1358 ArenaBoard vtable
0x004CE400 App vtable
0x004D0260 Scene vtable
0x004CF3A0 Ball vtable
Modding Tips
Tip 1: Detect Arena Mode
Check if current scene is a ArenaBoard:
Scene* scene = *(Scene**)(app + 0x178);
uint32_t vtable = *(uint32_t*)scene;
bool is_arena = (vtable == 0x004D1358);
Tip 2: Find ArenaBoard from App
In arena mode, App->currentScene IS the ArenaBoard:
void* rumbleBoard = *(void**)(app + 0x178);
Tip 3: Score Multiplier
Hook Rotator_AddBall and multiply all scores:
void __fastcall Hook_SetScore(void* this, int player_id) {
Original_SetScore(this, player_id);
// Walk the list at this+0x10F0 and multiply by 10
}
Tip 4: Disable Tie-Breaker
Force instant win even on ties:
// In ArenaBoard_Update, nop out the tie_count check
// Or simply never let timer expire:
*(int32_t*)(rumbleBoard + 0x47AC) = 999999;
Tip 5: Visual Score Popups
Create custom score notifications:
void* popup = operator_new(0x30);
ScoreObject_ctor(popup, app, player_ptr, "CUSTOM BONUS:");
*(int32_t*)(popup + 0x10) = 500; // Display value
Timer_Decrement(popup);
AthenaList_Append(rumbleBoard + 0x8B8, popup);
Files Referenced
| File | Description |
|---|---|
analysis/ghidra/decompilations/collision/decomp_expert_collisionevents.c |
Arena collision events |
analysis/ghidra/decompilations/tournament/decomp_rumbleboard_render.c |
HUD rendering |
analysis/ghidra/decompilations/scene/decomp_scene_updateballs.c |
Ball update logic |
analysis/ghidra/structs/rumbleboard_struct.h |
ArenaBoard C struct |
docs/RUMBLEBOARD_SYSTEM.md |
Arena architecture overview |
docs/ARENA_HAZARD_SYSTEM.md |
Hazard object documentation |
docs/APP_OBJECT.md |
App struct full reference |
Document compiled from live Ghidra decompilation. All offsets verified against Hamsterball.exe build. For questions or corrections, check the raw decompilation files in analysis/ghidra/decompilations/.
🔗 Related Documents
Asset Loading & Resource Manif
Asset Loading & Resource Manifest
Overview
TimerDisplay (0x4298C0) is actually App_ResourceLoader — it's the master asset loading
function that loads ALL game assets during the loading screen phase. It creates a
LoadingScreenGadget (0x3628 bytes) and calls its vtable methods to load resources.
LoadingScreenGadget Vtable Methods
| Offset | Method | Purpose |
|---|---|---|
| +0x48 | LoadMeshWithCollision | Load mesh + collision data (2nd arg=collision flag) |
| +0x4C | LoadMesh | Load mesh without collision |
| +0x50 | LoadLevel | Load MESHWORLD level |
| +0x54 | LoadLevelLinked | Load level + link to another level |
| +0x58 | LoadTexture | Load texture (PNG/BMP) |
| +0x5C | LoadFont | Load bitmap font |
| +0x60 | LoadSound | Load OGG sound effect (2nd arg=channel count) |
Complete Asset Manifest
Fonts (5)
| App Offset | Path | Purpose |
|---|---|---|
| +0x318 | fonts\showcardgothic28 | Main title/heads-up font (28pt) |
| +0x324 | fonts\arialnarrow12bold | UI body text (12pt bold) |
| +0x31C | fonts\showcardgothic14 | Small labels (14pt) |
| +0x328 | fonts\showcardgothic72 | Large display (72pt) |
| +0x320 | fonts\showcardgothic16 | Medium labels (16pt) |
Title Textures (3)
| App Offset | Path | Purpose |
|---|---|---|
| +0x348 | titletext-left.png | Title screen left half |
| +0x34C | titletext-right.png | Title screen right half |
| +0x330 | textures\hammy1.png | Hamster character 1 |
| +0x334 | textures\hammy2.png | Hamster character 2 |
| +0x338 | textures\hammy3.png | Hamster character 3 |
Sign & Goal Textures (18)
| App Offset | Path | Flag |
|---|---|---|
| +0x27C | sign-bewarethetar.png | 0 (no collision) |
| +0x280 | sign-bewarethetar.png | 1 (collision) |
| +0x284 | sign-bewarethetar-mirrored.png | 0 |
| +0x288 | arrow1.png | 0 |
| +0x28C | arrow1.png | 1 |
| +0x290 | arrow1-mirrored.png | 0 |
| +0x294 | goal.png | 0 |
| +0x298 | goal.png | 1 |
| +0x29C | goal-lit.png | 1 |
| +0x2A0 | goal-mirrored.png | 1 |
| +0x2A4 | goal-lit-mirrored.png | 1 |
| +0x2A8 | locktile.png | 0 |
| +0x2AC | locktile.png | 1 |
| +0x2B0 | locktile2.png | 1 |
| +0x2B4 | goal-round.png | 0 |
| +0x2B8 | goal-round.png | 1 |
| +0x2BC | goal-round-lit.png | 1 |
| +0x2C0 | goal-round-mirrored.png | 1 |
| +0x2C4 | goal-round-lit-mirrored.png | 1 |
Checker/Brick Textures (18)
| Path | Purpose |
|---|---|
| pinkchecker.bmp | Pink checker tile |
| bluechecker.bmp | Blue checker tile |
| bluebrick.png | Blue brick tile |
| greenchecker.bmp | Green checker tile |
| greenbrick.png | Green brick tile |
| yelllowchecker.png | Yellow checker tile (typo in original) |
| greyoutlinechecker.png | Grey outlined checker |
| redchecker.bmp | Red checker tile |
| redbrick.png | Red brick tile |
| orangechecker.bmp | Orange checker tile |
| orangebrick.png | Orange brick tile |
| brightgreenchecker.bmp | Bright green checker |
| brightgreenbrick.png | Bright green brick |
| toobchecker.png | Tube race checker |
| toobbrick.png | Tube race brick |
| skychecker.png | Sky race checker |
| purplechecker.bmp | Purple checker tile |
| purplebrick.png | Purple brick tile |
| brownbrick.png | Brown brick tile |
| blackchecker.png | Black checker tile |
Ball/Mesh Textures (12)
| Path | Purpose |
|---|---|
| blueblot.png | Blue blob background |
| blueblot2.png | Blue blob variant |
| bluecircle.png | Blue circle |
| chrome.png | Chrome ball texture |
| chromeshadow.png | Chrome ball shadow |
| weaselbox.png | Weasel ranking box |
| settext.png | Settings text |
| gotext.png | GO! text overlay |
| ballborder.png | Ball border frame |
| ballburner.png | Ball burner effect |
| sweat.png | Sweat drop sprite |
| raptisoftlogo.png | Developer logo |
Medal/Icon Textures (8)
| Path | Purpose |
|---|---|
| bronze-small.png | Bronze medal small |
| silver-small.png | Silver medal small |
| gold-small.png | Gold medal small |
| goldenweasel.png | Golden weasel award |
| bronze-icon.png | Bronze medal icon |
| silver-icon.png | Silver medal icon |
| gold-icon.png | Gold medal icon |
| goldenweasel-icon.png | Golden weasel icon |
Tournament Thumbnails (15)
| Path | Level |
|---|---|
| tourney-beginner.png | Beginner race |
| tourney-cascade.png | Cascade race |
| tourney-intermediate.png | Intermediate race |
| tourney-dizzy.png | Dizzy race |
| tourney-Tower.png | Tower race |
| tourney-Up.png | Up race |
| tourney-Neon.png | Neon race |
| tourney-Expert.png | Expert race |
| tourney-Odd.png | odd race |
| tourney-Toob.png | Toob race |
| tourney-Wobbly.png | Wobbly race |
| tourney-Glass.png | Glass race |
| tourney-Sky.png | Sky race |
| tourney-Master.png | Master race |
| tourney-Impossible.png | Impossible race |
Menu/Overlay Textures (8)
| Path | Purpose |
|---|---|
| tournament.png | Tournament mode splash |
| timetrials.png | Time trial splash |
| partyrace.png | Party race splash |
| rodentrumble.png | Rodent Rumble splash |
| mirror.png | Mirror mode icon |
| Burst.png | Burst effect |
| Lost.png | "You Lost" overlay |
| Winner.png | "You Won" overlay |
| Winner2p.png | "Player Won" 2P overlay |
| title1-4.png | Title screen frames |
| unlock.png | Unlock notification |
| scoreball.png | Score ball sprite |
Meshes (14)
| App Offset | Path | Purpose |
|---|---|---|
| +0x244 | Meshes\Sphere | Main ball mesh |
| +0x248 | Meshes\SphereBreak1 | Ball break animation 1 |
| +0x24C | Meshes\SphereBreak2 | Ball break animation 2 |
| +0x260 | Meshes\RBGlare | Reflection glare mesh |
| +0x264 | Meshes\Sphere+Tar | Tar-stuck ball mesh |
| +0x250 | Meshes\Hamster-Waiting | Hamster idle pose |
| +0x254 | Meshes\Hamster-trot1 | Hamster walk frame 1 |
| +0x258 | Meshes\Hamster-trot2 | Hamster walk frame 2 |
| +0x25C | Meshes\Hamster-trot3 | Hamster walk frame 3 |
| +0x268 | Meshes\8Ball | 8-ball mesh (tournament) |
| +0x26C | Meshes\FunBall | Fun ball mesh (arena) |
| +0x270 | Meshes\Bell | Bell mesh (tournament) |
| +0x274 | Meshes\Dizzy | Dizzy powerup mesh |
| +0x584 | Meshes\GlassBonus | Glass bonus mesh |
| +0x588 | Meshes\GlassBonus-Smashed | Glass bonus smashed |
| +0x5A4 | Meshes\tarbubble | Tar bubble mesh |
| +0x5A8 | Meshes\fanblades | Fan blades mesh |
| +0x5AC | Meshes\fanbody | Fan body mesh |
| +0x5B0 | Meshes\sawblade | Saw blade mesh |
| +0x5B4 | Meshes\sawface | Saw face mesh |
| +0x5B8 | Meshes\sawface2 | Saw face variant |
| +0x5BC | Meshes\dawgshoe | Blockdawg shoe 1 |
| +0x5C0 | Meshes\dawgshoe2 | Blockdawg shoe 2 |
| +0x5C4 | Meshes\dawgshadow | Blockdawg shadow |
| +0x578 | Meshes\mousetrapshadow | Mouse trap shadow |
Levels (7)
| App Offset | Path | Purpose |
|---|---|---|
| +0x570 | Levels\MouseTrap | Mouse trap level |
| +0x57C | Levels\Secret | Secret/unlock level |
| +0x580 | Levels\Secret-Unlock | Secret unlock level |
| +0x594 | Levels\Level4-Trapdoor1 | Trapdoor level 1 |
| +0x598 | Levels\Level4-Trapdoor2 | Trapdoor level 2 |
| +0x58C | Levels\PopupSign | Popup sign level |
| +0x5C8 | Levels\Level6-Lifter | Lifter level |
| +0x584 | (linked to MouseTrap) | Mouse trap collision |
| +0x59C | (linked to Trapdoor1) | Trapdoor 1 collision |
| +0x5A0 | (linked to Trapdoor2) | Trapdoor 2 collision |
| +0x590 | (linked to PopupSign) | Popup sign collision |
Sound Effects (55)
| App Offset | Path | Channels |
|---|---|---|
| +0x43C | sounds\collide | 10 |
| +0x440 | sounds\roll | 10 |
| +0x444 | sounds\whistle | 1 |
| +0x448 | sounds\bumper | 10 |
| +0x44C | sounds\ballbreak | 5 |
| +0x450 | sounds\ballbreaksmall | 5 |
| +0x454 | sounds\thwomp | 2 |
| +0x458 | sounds\snap | 2 |
| +0x45C | sounds\popup | 2 |
| +0x460 | sounds\dropin | 2 |
| +0x464 | sounds\dropinshort | 2 |
| +0x468 | sounds\popout | 2 |
| +0x46C | sounds\pipebump1 | 10 |
| +0x470 | sounds\pipebump2 | 10 |
| +0x474 | sounds\pipebump3 | 10 |
| +0x478 | sounds\gearclank | 20 |
| +0x47C | sounds\bridgeslam | 2 |
| +0x480 | sounds\platformtick | 5 |
| +0x484 | sounds\gluestuck | 5 |
| +0x488 | sounds\bubble1 | 5 |
| +0x48C | sounds\bubble2 | 5 |
| +0x490 | sounds\wheelcreak | 2 |
| +0x494 | sounds\catapult | 2 |
| +0x498 | sounds\trapdoor | 2 |
| +0x49C | sounds\fwing | 2 |
| +0x4A0 | sounds\clink | 3 |
| +0x4A4 | sounds\whoosh | 3 |
| +0x4A8 | sounds\chomp | 1 |
| +0x4AC | sounds\fan-start | 10 |
| +0x4B0 | sounds\fan-blow | 10 |
| +0x4B4 | sounds\crack | 2 |
| +0x4B8 | sounds\crumble | 2 |
| +0x4BC | sounds\sawstartup | 2 |
| +0x4C0 | sounds\sawcut | 2 |
| +0x4C4 | sounds\minipop | 5 |
| +0x4C8 | sounds\bell | 3 |
| +0x4CC | sounds\zip | 2 |
| +0x4D0 | sounds\ting | 20 |
| +0x4D4 | sounds\shrink | 3 |
| +0x4D8 | sounds\grow | 3 |
| +0x4DC | sounds\tweet | 3 |
| +0x4E0 | sounds\creakyplatform | 20 |
| +0x4E4 | sounds\wubba | 5 |
| +0x4E8 | sounds\saw | 2 |
| +0x4EC | sounds\sawspeedy | 2 |
| +0x4F0 | sounds\dawgstep1 | 10 |
| +0x4F4 | sounds\dawgstep2 | 10 |
| +0x4F8 | sounds\dawgsmash | 10 |
| +0x4FC | sounds\sizzle | 2 |
| +0x500 | sounds\explode | 3 |
| +0x504 | sounds\vac-o-sux | 3 |
| +0x508 | sounds\speedcylinder | 2 |
| +0x50C | sounds\bonuspop | 5 |
| +0x510 | sounds\buzzbonus | 1 |
| +0x514 | sounds\breakbridge | 1 |
| +0x518 | sounds\unlock | 1 |
| +0x51C | sounds\NeonRide | 1 |
| +0x520 | sounds\NeonFlicker | 50 |
| +0x524 | sounds\ZoopDown | 2 |
| +0x528 | sounds\LightsOff | 2 |
| +0x52C | sounds\GlassBonus | 2 |
RaceGoalReached Ctor (0x44C880)
When a player finishes a race:
- Sets title to "GOAL REACHED!"
- Loads "textures\ranks\weasel.png" rank sprite
- Updates best time in App+0x86C[player_index*4]
- 800ms timer (frames) for goal animation
- Medal thresholds hardcoded as integer values
🔗 Related Documents
Audio Modding Guide
Hamsterball Audio Modding Guide
This guide covers how the original Hamsterball Windows executable loads, plays, and mixes sound effects and music, and how a modder can trigger existing sounds or add new custom audio from inside a DLL hook.
It is built from live Ghidra decompilation of Hamsterball.exe and the existing AUDIO_SYSTEM.md / AUDIO_SYSTEM_SFX.md docs.
1. Audio architecture
Hamsterball uses two completely separate audio APIs:
| System | Library | Purpose | file formats |
|---|---|---|---|
| Music | BASS.dll (un4seen) | Background music, menu stings | .mo3 tracker modules |
| Sound effects | DirectSound8 (dsound.dll) |
3D positional gameplay sounds | .ogg (preferred), .wav (fallback) |
Both systems live under the single App object. The audio device object is at App + 0x178 (SoundDevice). Music channels are stored at App + 0x534, 0x538, 0x53C.
2. Key structs
2.1 SoundDevice — the DirectSound8 manager
Created by SoundDevice_ctor (0x00466620). It wraps the IDirectSound8 interface and owns all loaded sound buffers.
| Offset | Type | Description |
|---|---|---|
+0x000 |
vtable* | SoundDevice vtable (0x4D911C) |
+0x004 |
App* |
Back-pointer to owning App |
+0x008 |
int |
Count of SoundList objects in the master list |
+0x00C |
int |
Capacity of that list |
+0x010 |
int* |
Array pointer for the master SoundList list |
+0x414 |
int* |
Same array pointer (redundant) |
+0x834 |
App* |
Cached App pointer |
+0x838 |
float |
Global SFX volume (read from registry "Sound Volume") |
+0x83C |
bool |
Sound enabled flag |
+0x84C |
IDirectSound8* |
DirectSound8 COM interface |
+0x850 |
int |
Number of 3D listeners |
+0x854 |
Vec3[16] |
Listener positions for 3D attenuation |
+0x914 |
float |
Min rolloff distance (default 0.0) |
+0x918 |
float |
Max rolloff distance (default 6000.0f) |
The global volume is read by Level_ReadSoundVolume (0x00466570) and written back by SoundDevice_dtor (0x004668A0).
2.2 SoundList — one logical sound with N hardware buffers
A SoundList is an AthenaList that owns one or more SoundEntry objects. Each SoundEntry wraps one DirectSound buffer handle. Multiple entries allow the same sound to be played polyphonically.
| Offset | Type | Description |
|---|---|---|
+0x000 |
vtable* | SoundList vtable (0x4D8E7C) |
+0x004 |
App* |
Back-pointer |
+0x008 |
AthenaList |
List of SoundEntry* buffers |
+0x00C |
int |
Entry count |
+0x010 |
int |
Circular "next" index for playback |
+0x414 |
int* |
Entry array pointer |
Object size is 0x420 bytes.
2.3 SoundEntry — one DirectSound buffer
| Offset | Type | Description |
|---|---|---|
+0x000 |
vtable* | SoundEntry scalar dtor (0x4D8E78) |
+0x004 |
App* |
Back-pointer |
+0x008 |
IDirectSoundBuffer* |
DirectSound buffer handle |
2.4 MusicChannel / MusicPlayer — BASS music
| Offset | Type | Description |
|---|---|---|
+0x000 |
vtable* | MusicChannel dtor |
+0x004 |
DWORD |
BASS device handle |
+0x008 |
HMUSIC |
BASS music handle |
+0x00C |
AthenaList |
Track list (name → handle) |
+0x418 |
int* |
Track array |
+0x528 |
float |
Current music volume |
+0x530 |
byte |
Muted flag |
+0x531 |
byte |
Paused flag |
MusicPlayer (App + 0x534) also stores the loaded file path at +0x424.
3. How the game loads SFX at startup
App_ResourceLoader (misnamed TimerDisplay, 0x0042A8C0) is the master loader. It creates a LoadingScreenGadget and calls its vtable methods to queue every asset. Slot +0x60 queues a sound load.
// LoadingScreenGadget vtable
+0x48 LoadTexture(dest, filename, alpha_flag)
+0x4C LoadMesh (dest, filename)
+0x50 LoadLevel (dest, filename)
+0x54 LoadCollision(dest, level_src)
+0x58 LoadSprite(dest, filename)
+0x5C LoadFont (dest, font_path)
+0x60 LoadSound (dest, sound_path, max_channels)
Example queue call from the loader:
LoadingScreenGadget* loader = *(LoadingScreenGadget**)(app + 0x22C);
(**(code**)(*(int*)loader + 0x60))(loader, app + 0x460, "sounds\\dropin", 2);
The dest argument is an App offset, not a pointer. The loader writes the resulting SoundList* into App + dest.
3.1 Full startup sound → App offset map
| App offset | Sound name | Max channels | Notes |
|---|---|---|---|
+0x43C |
sounds\\collide |
10 | Ball/ball and ball/wall impacts |
+0x440 |
sounds\\roll |
10 | Rolling loop |
+0x444 |
sounds\\whistle |
1 | Spin/whistle |
+0x448 |
sounds\\bumper |
10 | Bumper impacts |
+0x44C |
sounds\\ballbreak |
5 | Large break |
+0x450 |
sounds\\ballbreaksmall |
5 | Small break |
+0x454 |
sounds\\thwomp |
2 | Thwomp hazard |
+0x458 |
sounds\\snap |
2 | Snap hazard |
+0x45C |
sounds\\popup |
2 | Popup sign |
+0x460 |
sounds\\dropin |
2 | DROPIN event |
+0x464 |
sounds\\dropinshort |
2 | Short drop |
+0x468 |
sounds\\popout |
2 | POPOUT event |
+0x46C |
sounds\\pipebump1 |
10 | PIPEBONK random set |
+0x470 |
sounds\\pipebump2 |
10 | PIPEBONK random set |
+0x474 |
sounds\\pipebump3 |
10 | PIPEBONK random set |
+0x478 |
sounds\\gearclank |
20 | Gear mechanisms |
+0x47C |
sounds\\bridgeslam |
2 | Bridge slam |
+0x480 |
sounds\\platformtick |
5 | Ticking platform |
+0x484 |
sounds\\gluestuck |
5 | Tarpit / glue |
+0x488 |
sounds\\bubble1 |
5 | Bubble sound 1 |
+0x48C |
sounds\\bubble2 |
5 | Bubble sound 2 |
+0x490 |
sounds\\wheelcreak |
2 | Creaking wheel |
+0x494 |
sounds\\catapult |
2 | Catapult launch |
+0x498 |
sounds\\trapdoor |
2 | Trapdoor |
+0x49C |
sounds\\fwing |
2 | Fwing hazard |
+0x4A0 |
sounds\\clink |
3 | Metal clink |
+0x4A4 |
sounds\\whoosh |
3 | Whoosh |
+0x4A8 |
sounds\\chomp |
1 | Chomper |
+0x4AC |
sounds\\fan-start |
10 | Fan startup |
+0x4B0 |
sounds\\fan-blow |
10 | Fan loop |
+0x4B4 |
sounds\\crack |
2 | Crack |
+0x4B8 |
sounds\\crumble |
2 | Crumble |
+0x4BC |
sounds\\sawstartup |
2 | Saw startup |
+0x4C0 |
sounds\\sawcut |
2 | Saw cutting |
+0x4C4 |
sounds\\minipop |
5 | Mini pop |
+0x4C8 |
sounds\\bell |
3 | Bell |
+0x4CC |
sounds\\zip |
2 | Zip (mouse-trap / fast move) |
+0x4D0 |
sounds\\ting |
20 | Ting |
+0x4D4 |
sounds\\shrink |
3 | Shrink powerup |
+0x4D8 |
sounds\\grow |
3 | Grow powerup |
+0x4DC |
sounds\\tweet |
3 | Tweet |
+0x4E0 |
sounds\\creakyplatform |
20 | Creaky platform |
+0x4E4 |
sounds\\wubba |
5 | Wubba |
+0x4E8 |
sounds\\saw |
2 | Saw loop |
+0x4EC |
sounds\\sawspeedy |
2 | Speedy saw |
+0x4F0 |
sounds\\dawgstep1 |
10 | Dawg step 1 |
+0x4F4 |
sounds\\dawgstep2 |
10 | Dawg step 2 |
+0x4F8 |
sounds\\dawgsmash |
10 | Dawg smash |
+0x4FC |
sounds\\sizzle |
2 | Sizzle |
+0x500 |
sounds\\explode |
3 | Explosion |
+0x504 |
sounds\\vac-o-sux |
3 | Vacuum |
+0x508 |
sounds\\speedcylinder |
2 | Speed cylinder |
+0x50C |
sounds\\bonuspop |
5 | Bonus pop |
+0x510 |
sounds\\buzzbonus |
1 | Buzz bonus |
+0x514 |
sounds\\breakbridge |
1 | Break bridge |
+0x518 |
sounds\\unlock |
1 | Unlock |
+0x51C |
sounds\\NeonRide |
1 | Neon ride |
+0x520 |
sounds\\NeonFlicker |
50 | Neon flicker |
+0x524 |
sounds\\ZoopDown |
2 | Zoop down |
+0x528 |
sounds\\LightsOff |
2 | Lights off |
+0x52C |
sounds\\GlassBonus |
2 | Glass bonus |
All of these are stored as SoundList* pointers inside App.
4. Core SFX engine functions
| Address | Name | Notes |
|---|---|---|
0x00458F20 |
SoundList_Ctor |
__thiscall (SoundList* this, App* app) |
0x00466620 |
SoundDevice_ctor |
Creates DirectSound8 device |
0x00458F40 |
SoundList_LoadWAV |
Manual WAV parser → DirectSound buffer |
0x00459310 |
Sound_LoadOgg |
OGG Vorbis decoder → DirectSound buffer |
0x00459660 |
Sound_LoadOggOrWav |
Tries name.ogg, falls back to name.wav |
0x00466500 |
Sound_LoadAndAppend |
Allocates SoundList, constructs it, loads file, appends to master list |
0x00459810 |
Sound_GetNextChannel |
Circular allocator from a SoundList |
0x004597B0 |
Sound_PlayChannel |
Plays the next available buffer in a list |
0x00459860 |
Sound_Play3D |
Computes distance attenuation, then plays |
0x00458EE0 |
Sound_Play3DAtPosition |
Applies the attenuated volume to a channel |
0x00466570 |
Level_ReadSoundVolume |
Reads registry volume into SoundDevice+0x838 |
0x00466750 |
Sound_CalculateDistanceAttenuation |
Linear 3D rolloff from nearest listener |
4.1 Sound_LoadOggOrWav behavior
void __thiscall Sound_LoadOggOrWav(SoundList* this, const char* baseName);
It builds the filenames "baseName.ogg" and "baseName.wav" and checks file access. OGG is tried first. If neither file exists, nothing is loaded and the list stays empty.
4.2 Sound_PlayChannel behavior
void __fastcall Sound_PlayChannel(SoundList* soundList);
- If sound is disabled or global volume is
0, returns. - Grabs the next
SoundEntry*from the circular list. - Calls DirectSound
Stop→SetVolume→Play. - The volume passed to DirectSound comes from
Audio_ClampPanValue()and the global volume.
4.3 Sound_Play3D behavior
void __thiscall Sound_Play3D(SoundList* soundList, float x, float y, float z);
- Calls
Sound_CalculateDistanceAttenuationusing all listeners. - Calls
Sound_PlayChannel.
For menu/UI sounds where you do not want 3D attenuation, call Sound_PlayChannel directly on the list.
5. How to trigger an existing sound effect
Because every SFX is already loaded into an App offset, the easiest trigger is:
// Get App base. In a hook you usually already have this.
App* app = GetAppSomehow();
// Example: play the "dropin" sound at full volume (no 3D)
SoundList* dropin = *(SoundList**)((char*)app + 0x460);
if (dropin) {
typedef void (__fastcall *Sound_PlayChannel_t)(SoundList*);
static auto fn = (Sound_PlayChannel_t)0x004597B0;
fn(dropin);
}
// Example: play "fwing" at a 3D position
SoundList* fwing = *(SoundList**)((char*)app + 0x49C);
if (fwing) {
typedef void (__thiscall *Sound_Play3D_t)(SoundList*, float, float, float);
static auto fn = (Sound_Play3D_t)0x00459860;
fn(fwing, x, y, z);
}
5.1 Triggering sounds from collision events
The engine already wires many level tags to sounds in DispatchCollisionEvents (a.k.a. DispatchCollisionEvents, 0x0040C5D0) and TowerCollisionEvents (0x0040DCD0):
| Tag | Sound source | Function called |
|---|---|---|
E:JUMP |
App + 0x49C (sounds\\fwing) |
Sound_Play3D |
N:TARPIT |
App + 0x484 (sounds\\gluestuck) |
Sound_Play3D |
DROPIN |
App + 0x460 (sounds\\dropin) |
Sound_PlayChannel |
PIPEBONK |
random App + 0x46C/0x470/0x474 |
Sound_Play3D |
POPOUT |
App + 0x468 (sounds\\popout) |
Sound_PlayChannel |
N:GOAL |
Audio_PlayMusic(App+0x53C, "Goal!") |
BASS |
E:CATAPULTBOTTOM |
App + 0x464 (sounds\\dropinshort) |
Sound_PlayChannel |
So adding an object with one of these tags in a MESHWORLD level already produces the corresponding sound. For custom tags, hook DispatchCollisionEvents and call the sound yourself.
6. How to add a completely custom sound
There are two practical approaches.
6.1 Option A: load during app initialization via the loader queue
If your DLL is loaded early enough (before App_ResourceLoader finishes), you can queue a sound load exactly like the game does:
void QueueCustomSound(App* app, const char* path, int maxChannels) {
// App+0x22C = LoadingScreenGadget* loader
void* loader = *(void**)((char*)app + 0x22C);
if (!loader) return;
// Pick an unused App offset. The block +0x530..+0x52C is the last used SFX region,
// so +0x5B0, +0x5B4, etc. are safe unused slots.
int destOffset = 0x5B0;
void** vtable = *(void***)loader;
((void(__thiscall*)(void*, int, const char*, int))(vtable[0x60 / 4]))
(loader, destOffset, path, maxChannels);
}
After the loader merges its lists, App + 0x5B0 will hold a SoundList* you can play with Sound_PlayChannel.
6.2 Option B: load a sound manually at runtime (recommended)
This works any time after SoundDevice_ctor has run.
// Operator new from the original CRT
void* operator_new(size_t);
typedef void* (__thiscall *SoundList_Ctor_t)(void* this_ptr, App* app);
typedef void (__thiscall *Sound_LoadOggOrWav_t)(void* this_ptr, const char* baseName);
typedef void (__fastcall *Sound_PlayChannel_t)(void* soundList);
void* LoadCustomSound(App* app, const char* baseName) {
static auto op_new = (void*(*)(size_t))0x004BA57B;
static auto ctor = (SoundList_Ctor_t)0x00458F20;
static auto load = (Sound_LoadOggOrWav_t)0x00459660;
void* soundList = op_new(0x420);
if (!soundList) return nullptr;
ctor(soundList, app);
load(soundList, baseName); // tries baseName.ogg then baseName.wav
return soundList;
}
void PlayCustomSound(void* soundList) {
static auto play = (Sound_PlayChannel_t)0x004597B0;
if (soundList) play(soundList);
}
void PlayCustomSound3D(void* soundList, float x, float y, float z) {
typedef void (__thiscall *fn_t)(void*, float, float, float);
static auto play3d = (fn_t)0x00459860;
if (soundList) play3d(soundList, x, y, z);
}
Place MySound.ogg (or .wav) in the game root or under Sounds/ and call:
void* mySound = LoadCustomSound(app, "sounds\\MySound"); // or just "MySound"
PlayCustomSound(mySound);
The file path is passed to _check_file_access, so relative paths work from the game's working directory.
6.3 Notes on custom sounds
- Format: OGG Vorbis is preferred. WAV is parsed manually and must be standard PCM RIFF/WAVE.
- Channels / polyphony:
maxChannelsin the loader queue controls how many duplicate DirectSound buffers are created. For manual loads, the game only creates one buffer per file; rapid re-triggers will cut off the previous instance. If you need polyphony, load the same file into multipleSoundListobjects and rotate between them, or use the loader queue. - 3D attenuation:
Sound_Play3Duses the listener array atSoundDevice+0x854. In single-player, listener 0 is the camera/ball position. In multiplayer, each ball is a listener. - Global volume:
Sound_PlayChannelalready multiplies bySoundDevice+0x838, so custom sounds respect the user's volume setting.
7. Music system (BASS)
7.1 BASS imports
| Address | Function | Typical call |
|---|---|---|
0x00487E40 |
BASS_SetConfig |
Configuration |
0x00487E4C |
BASS_Init |
BASS_Init(-1, 44100, 0, 0, NULL) |
0x00487E46 |
BASS_Start |
Resume |
0x00487E58 |
BASS_Stop |
Pause all |
0x00487E52 |
BASS_Free |
Shutdown |
0x004794B0 |
BASS_ErrorGetCode |
Last error |
0x004794B6 |
BASS_MusicLoad |
Load .mo3/.xm/.it |
0x004794BC |
BASS_ChannelStop |
Stop channel |
0x004794C2 |
BASS_ChannelSetAttributes |
Freq/volume/ramp |
0x004794C8 |
BASS_MusicPlayEx |
Play module |
7.2 Music channels
| App offset | Type | Use |
|---|---|---|
+0x534 |
MusicPlayer* |
Main music player, file path at +0x424 |
+0x538 |
HMUSIC |
Music channel 2 |
+0x53C |
HMUSIC |
Music channel 1 (used by Audio_PlayMusic) |
7.3 Playing a music track by name
typedef void (__thiscall *Audio_PlayMusic_t)(void* musicChannel, const char* trackName);
static auto playMusic = (Audio_PlayMusic_t)0x0046A310;
void* channel = *(void**)((char*)app + 0x53C);
playMusic(channel, "Goal!"); // plays the track named "Goal!" from the loaded MO3
Audio_PlayMusic (0x0046A310) searches the track list linearly by name, sets volume to 1.0, and calls BASS_MusicPlayEx.
7.4 Tempo control
typedef void (__thiscall *Audio_PlayMusicAtSpeed_t)(void* musicChannel, const char* trackName, float speed);
static auto playSpeed = (Audio_PlayMusicAtSpeed_t)0x0046A440;
playSpeed(channel, "Intro", 4.0f); // 4x speed
playSpeed(channel, "Race", 2.0f); // normal-ish
7.5 Stopping music
typedef void (__fastcall *Audio_StopChannel_t)(void* musicChannel);
static auto stopMusic = (Audio_StopChannel_t)0x0046A0D0;
stopMusic(channel);
7.6 Jukebox / track list
jukebox.xml defines named tracks:
<jukebox>
<SONG>
<NAME>Goal!</NAME>
<HEX>1A</HEX>
</SONG>
</jukebox>
LoadJukebox (0x0046A4D0) parses this and stores name→handle mappings. If you add a custom music track, you must either:
- Edit
jukebox.xmland the.mo3file, or - Use
BASS_MusicLoaddirectly and callBASS_MusicPlayExyourself.
7.7 Custom music with BASS directly
typedef DWORD (__stdcall *BASS_MusicLoad_t)(BOOL mem, void* file, DWORD offset, DWORD length, DWORD flags, DWORD freq);
typedef BOOL (__stdcall *BASS_MusicPlayEx_t)(DWORD handle, DWORD pos, BOOL restart, DWORD flags);
typedef BOOL (__stdcall *BASS_ChannelStop_t)(DWORD handle);
static auto BASS_MusicLoad = (BASS_MusicLoad_t)0x004794B6;
static auto BASS_MusicPlayEx = (BASS_MusicPlayEx_t)0x004794C8;
static auto BASS_ChannelStop = (BASS_ChannelStop_t)0x004794BC;
DWORD h = BASS_MusicLoad(0, "music\\custom.mo3", 0, 0, 4, 0);
if (h) BASS_MusicPlayEx(h, 0, TRUE, 0);
Flag 4 is BASS_MUSIC_PRESCAN.
8. Volume and 3D math
8.1 Reading/changing SFX volume
float* volume = (float*)((char*)soundDevice + 0x838);
*volume = 0.75f; // 75% SFX volume
The registry key "Sound Volume" is read at startup and saved on shutdown.
8.2 3D attenuation formula
Sound_CalculateDistanceAttenuation (0x00466750) does:
- Find nearest listener in
SoundDevice+0x854. dist = distance(soundPos, nearestListener).- If
dist <= minRolloff→ return1.0. - If
dist >= maxRolloff→ return0.0. - Else linear:
1.0 - (dist - minRolloff) / (maxRolloff - minRolloff).
Default maxRolloff is 6000.0f (0x45BB8000).
9. Code example: a complete custom sound hook
#include <windows.h>
// Type defs
using SoundList_Ctor_t = void* (__thiscall*)(void*, void*);
using Sound_LoadOggOrWav_t = void (__thiscall*)(void*, const char*);
using Sound_PlayChannel_t = void (__fastcall*)(void*);
using Sound_Play3D_t = void (__thiscall*)(void*, float, float, float);
using operator_new_t = void* (*)(size_t);
static SoundList_Ctor_t SoundList_Ctor = (SoundList_Ctor_t) 0x00458F20;
static Sound_LoadOggOrWav_t Sound_LoadOggOrWav = (Sound_LoadOggOrWav_t)0x00459660;
static Sound_PlayChannel_t Sound_PlayChannel = (Sound_PlayChannel_t) 0x004597B0;
static Sound_Play3D_t Sound_Play3D = (Sound_Play3D_t) 0x00459860;
static operator_new_t operator_new = (operator_new_t) 0x004BA57B;
void* g_CustomSound = nullptr;
void InitCustomSound(void* app) {
if (g_CustomSound) return;
void* list = operator_new(0x420);
if (!list) return;
SoundList_Ctor(list, app);
Sound_LoadOggOrWav(list, "sounds\\MyCustom");
g_CustomSound = list;
}
void PlayCustomSFX(void* app, float x, float y, float z) {
InitCustomSound(app);
if (!g_CustomSound) return;
Sound_Play3D(g_CustomSound, x, y, z);
}
void PlayCustomUI(void* app) {
InitCustomSound(app);
if (!g_CustomSound) return;
Sound_PlayChannel(g_CustomSound);
}
Drop MyCustom.ogg into Sounds/MyCustom.ogg (or the game root) and call PlayCustomUI(app) from any hook.
10. Reimplementation / replacement notes
If you are rebuilding the audio layer for a port:
- BASS music: replace with SDL_mixer, OpenAL, or miniaudio + libopenmpt / libxmp for
.mo3/.xm/.it. - DirectSound8 SFX: replace with SDL_mixer
Mix_Chunkchannels or OpenAL sources. For true 3D, OpenAL gives you the listener/distance model for free. - Channel pool: the original uses a circular allocator across duplicated buffers. A modern replacement can just allocate dynamic channels.
- OGG/WAV: both are trivial with stb_vorbis / dr_wav or SDL_mixer.
- Volume scale: DirectSound uses dB attenuation (
-10000to0). SDL_mixer uses0..128. Convert linear0..1→ mixer volume.
11. Quick reference: call these addresses
| What you want | Call |
|---|---|
| Play existing SFX at full volume | Sound_PlayChannel(App+offset) (0x004597B0) |
| Play existing SFX at a 3D position | Sound_Play3D(App+offset, x, y, z) (0x00459860) |
| Load custom SFX at runtime | SoundList_Ctor → Sound_LoadOggOrWav (0x00458F20, 0x00459660) |
| Play a BASS music track | Audio_PlayMusic(App+0x53C, name) (0x0046A310) |
| Stop BASS music | Audio_StopChannel(channel) (0x0046A0D0) |
| Load BASS file directly | BASS_MusicLoad (0x004794B6) |
| Read global SFX volume | *(float*)(SoundDevice + 0x838) |
Generated from GhidraMCP decompilation of Hamsterball.exe at commit time. Addresses assume the default image base 0x00400000. If you are hooking a relocated process, convert to RVA first: RVA = addr - 0x400000, then real = GetModuleHandle(NULL) + RVA.
🔗 Related Documents
Audio System
Audio System
Overview
Hamsterball uses a dual audio system:
- BASS.dll (shipped alongside exe) for music playback (MO3 tracker format)
- DirectSound (dsound.dll) for 3D positional sound effects (OGG)
BASS Music System
API Calls
| Address | Function | Parameters |
|---|---|---|
| 0x487E40 | BASS_SetConfig | Config options |
| 0x487E4C | BASS_Init | device=-1, freq=44100, flags=0, win=NULL |
| 0x487E46 | BASS_Start | Resume playback |
| 0x487E58 | BASS_Stop | Pause all playback |
| 0x487E52 | BASS_Free | Shutdown |
| 0x4794B6 | BASS_MusicLoad | Load .MO3 file |
| 0x4794C8 | BASS_MusicPlayEx | Play with flags |
| 0x4794C2 | BASS_ChannelSetAttributes | freq, volume, ramp |
| 0x4794BC | BASS_ChannelStop | Stop channel |
| 0x4794B0 | BASS_ErrorGetCode | Last error |
MusicChannel Struct (0x534 bytes)
| Offset | Type | Field |
|---|---|---|
| +0x00 | vtable* | MusicChannel_DeletingDtor |
| +0x04 | DWORD | BASS device handle |
| +0x0C | AthenaList | Track list |
| +0x10 | int | Track count |
| +0x418 | AthenaList | Track data array |
| +0x528 | float | Current volume (1.0 default) |
| +0x530 | byte | Muted flag |
| +0x531 | byte | Paused flag |
Music Playback
App_Initialize_Full step 7:
music_channel = MusicChannel_LoadAndAppend(App+0x17C, "music\\music.mo3")
Audio_PlayMusic(channel, track_name):
Linear search track list (this+0x418) for matching name
Set volume = 1.0
BASS_ChannelSetAttributes(handle, -1, 100, fade_ramp)
BASS_MusicPlayEx(handle, track_handle, restart, flags)
Audio_PlayMusicAtSpeed(channel, track_name, speed):
Same but with tempo control
speed=4.0 for intro, speed=2.0 for normal
MusicPlayer_ctor (0x426030):
Creates jukebox player for XML-driven music sequences
MusicPlayer_SetTempoScale (0x46A140):
Adjusts playback speed (2.0=normal, 4.0=fast forward)
MusicDevice_FadeAll (0x474780):
Fade all music channels simultaneously
Jukebox XML Format
jukebox.xml contains song sequences:
<jukebox>
<song name="TrackName" file="music\track.mo3"/>
...
</jukebox>
LoadJukebox("jukebox.xml") loads this at startup
DirectSound SFX System
SoundDevice Struct
Created at 0x466620 (SoundDevice_ctor).
Manages DirectSound buffer pool for 3D positional audio.
Sound List
| Address | Function | Description |
|---|---|---|
| 0x458F20 | SoundList_Ctor | Sound list constructor |
| 0x59700 | SoundList_DtorInner | Cleanup inner data |
| 0x59570 | SoundEntry_ScalarDtor | Individual sound destructor |
| 0x466320 | SoundChannel_Ctor | DirectSound channel (0x80 bytes) |
| 0x466C90 | SoundBuffer_ScalarDtor | DSound buffer destructor |
| 0x466A10 | SoundDevice_UpdateChannels | Per-frame mix/update |
| 0x466B80 | SoundDevice_Play3DAll | 3D positional sound playback |
| 0x4ABAFAF | DSound_SetVolume | Volume control |
3D Sound Effects
Each sound effect is played at a 3D position:
- SceneObject_SpawnWithSound (0x439A30) - Play sound at object position
- Audio position comes from Scene+0x29C0 (camera distance)
- Sound attenuation: 3D falloff based on distance
Level-Specific Sound Functions
| Address | Function | Description |
|---|---|---|
| 0x435B00 | CollisionLevel_PlayBreakSound | Collision break sound |
| 0x437300 | Lifter_PlaySound | Lifter mechanism sound |
| 0x436B70 | Pendulum_PlayCollisionSound | Pendulum impact sound |
| 0x434030 | Rotator_PlayCollisionSound | Rotator impact sound |
| 0x4367E0 | Rotator_StartSound | Rotator running sound |
| 0x436CF0 | Rotator_TriggerSound | Rotator trigger effect |
| 0x434AB0 | Sawblade_SetBreakSound | Sawblade break sound |
| 0x401090 | Scene_SetSoundMode | Toggle sound mode |
Menu Sound
| Address | Function | Description |
|---|---|---|
| 0x475430 | Menu_AddSound | Play menu click/transition sound |
Sound Volume
| Address | Function | Description |
|---|---|---|
| 0x466570 | Level_ReadSoundVolume | Read volume from registry |
| 0x474480 | MusicDevice_SetVolume | Set master music volume |
| 0x4744B0 | MusicDevice_ReadVolume | Read current volume |
| 0x474510 | MusicDevice_MuteToggle | Toggle mute |
App Audio Initialization
App_CreateAudioDevice (0x46C0B0):
Creates DirectSound device for SFX
Allocates 16 channels for sound mixing
App_Initialize_Full step 7-11:
- Load "music\music.mo3" via BASS
- Load "jukebox.xml" for track sequencing
- RegKeyList_CopyFromSibling for music channel linking
- Two music channels: primary (App+0x534) and secondary (App+0x53C/0x538)
Level_InitScene (0x40B090):
- Create SoundChannel (0x80 bytes) for level SFX
- Set volume to -50.0 dB initially
- Play level music at speed 2.0 (normal) or 4.0 (intro skip)
🔗 Related Documents
BadBall (8-Ball) AI Circling S
BadBall (8-Ball) AI Circling System
Function: Ball_AI_ChaseNearest (vtable[4], 0x408390)
Created via GhidraMCP create_function (was not in function table). Body size: 1184 bytes.
Activation Gate
if ((param_1[0x31d] != 0) || (*(char *)(param_1[4] + 0x237) != '\0')) {
// Run AI
}
Two paths to AI activation:
ball+0xC74(C-index 0x31D) =is_8ballflag ≠ 0 — set by Ball_InitBattleModeApp+0x237≠ 0 — scene is in tournament/demo mode (App is at ball+0x10 via param_1[4])
AI Pipeline (in order)
-
Save previous frame's is_active state, call
Ball_Update(param_1)(0x405E00 — full 23-phase physics tick) -
Score proximity check — if a ball just became inactive (transition from active→inactive), scan all balls within
_DAT_004cf554(3000.0 units) and award score via Difficulty_GetTimeModifier × 2000.0 -
Compute distance from home —
fVar1= 2D distance from ball pos to home position (param_1[0x318..0x31A]) -
Find nearest player ball — iterate board+0x29D4 AthenaList, filter by:
ball+0x768≠ 0 (is active/alive)ball+0x2F9== 0 (NOT falling)ball+0x300== 0 (not in some special state)ball+0x324== 0 (not flagged)scene+0x3A4C≠ 0 (countdown finished / race active)ball+0x18≠ -1 (has a player index)
Track nearest by
Math_FastDistance2D. -
Orbit computation (if distance from home < 220.0):
// _DAT_004cf550 = 220.0 (orbit threshold) if (dist_from_home < 220.0) { target_x = sin(spin_angle) × SPINDISTANCE + home_x; target_z = cos(spin_angle) × SPINDISTANCE + home_z; }spin_angle=param_1[0x31e]= ball+0xC78 (float, radians)SPINDISTANCE=param_1[799]= ball+0xC7C (float, orbit radius)- Uses
Wave_SinandWave_Cos(not standard C math)
-
Angle increment (always, every frame):
param_1[0x31e] += _DAT_004cf48c; // += 2.0 radians per frame_DAT_004cf48c = 2.0 (0x40000000). This is CONSTANT — the angular speed of the orbit is always the same.
-
Chase/flee override (if player ball found within CHASE+HOME):
if (nearest_ball != 0 && dist_from_home < HOME && nearest_dist < CHASE) { target_x = nearest_ball->pos_x; // +0x164 target_z = nearest_ball->pos_z; // +0x16C // FLEE if player is bigger if (ball+0x284 (own_radius) < nearest_ball+0x284 × _DAT_004cf508) { target_x = -target_x; target_z = -target_z; } }_DAT_004cf508 = 0.6666667 (2/3) — flee if own radius < player radius × 0.667
-
Apply force toward target — normalize direction vector, call vtable[5] (0x14 offset):
dx = target_x - ball_pos_x; dz = target_z - ball_pos_z; dist = sqrt(dx² + dz²); if (dist > 0) { inv = 1.0 / dist; dx *= inv; dz *= inv; } (*vtable[5])(dx, 0, dz); // Ball_ApplyForceWithMultipliers
Key Constants (verified from .data section)
| Address | Hex | Float | Purpose |
|---|---|---|---|
| 0x4CF550 | 00005C43 | 220.0 | Orbit threshold: if dist_from_home < this, do orbit |
| 0x4CF48C | 00000040 | 2.0 | Spin angle increment per frame (radians) |
| 0x4CF508 | 66666666 | 0.667 | Flee ratio: flee if own_radius < player_radius × 0.667 |
| 0x4CF554 | 00803B45 | 3000.0 | Score award distance (when ball deactivates near player) |
| 0x4CF558 | 0000804F | 250000.0(?) | FastDistance2D correction constant |
| 0x4CF368 | 00000000 | 0.0 | Zero constant (used as floor/clamp) |
| 0x4CF310 | 0000803F | 1.0 | One constant (used for normalization) |
MESHWORLD Tag Values Per Level
| Level File | Race | CHASE | HOME | SPINDISTANCE | Notes |
|---|---|---|---|---|---|
| LevelCascade | Beginner | 300 | 375 | NOT SPECIFIED (default 50.0) | No SPINDISTANCE tag — uses InitPhysics default |
| Level2 | Intermediate | — | — | — | (BADBALL not present in this level) |
| Level3 | Dizzy | 200 | 300 | 45 | Explicit orbit radius |
SPINDISTANCE Initialization — VERIFIED (June 2026)
VERIFIED by decompilation of Ball_InitPhysicsDefaults (0x00405100):
void __fastcall Ball_InitPhysicsDefaults(void *param_1) {
Ball_SetupCollisionRender((int)param_1);
Vec3_Init(&stack, 0, 0, 0);
Ball_SetTrajectory(param_1, ...);
*(int*)(param_1 + 0x18) = -1; // player_index = none
*(float*)(param_1 + 0x278) = 0.5; // gravity_scale
*(float*)(param_1 + 0x27c) = 0.2; // unknown
*(float*)(param_1 + 0x284) = 35.0; // radius
*(float*)(param_1 + 0x1a0) = 0.2; // speed_scale
*(float*)(param_1 + 0x188) = 6.0; // max_speed
*(float*)(param_1 + 0xC78) = 0.0; // spin_angle = 0 (radians)
*(float*)(param_1 + 0xC7C) = 50.0; // SPINDISTANCE = 50.0 (orbit radius)
*(float*)(param_1 + 0xC6C) = 600.0; // CHASE default = 600.0
*(float*)(param_1 + 0xC70) = 1200.0; // HOME default = 1200.0
}
SPINDISTANCE IS initialized to 50.0 — it is NOT uninitialized heap garbage. The orbit radius on Beginner (where no SPINDISTANCE MESHWORLD tag is present) is deterministically 50.0 units.
This means behavioral variance on Beginner comes from:
- Timing of player entry — spin_angle increments 2.0 rad/frame. At 25fps that's ~50 rad/s ≈ 8 full revolutions/s. The orbit position when the player enters determines initial approach direction.
- Orbit vs chase transition — entering CHASE range (300 on Beginner) overrides orbit with direct chase. The transition point relative to the orbit cycle makes it look more or less aggressive.
- Player radius vs 8-ball radius — if player is bigger, 8-Ball flees instead of chases (× 0.667 ratio check).
- Ball_Update phase interactions — Ball_AI_ChaseNearest calls Ball_Update every frame; physics state (velocity, position on slopes) at the moment of chase transition affects how aggressively the ball curves.
Ball Field Layout (AI-relevant offsets)
| Byte Offset | C-Index | Type | Field | Set By |
|---|---|---|---|---|
| 0xC60 | 0x318 | float | home_x | CreateBadBall (from MW coords) |
| 0xC64 | 0x319 | float | home_y | CreateBadBall |
| 0xC68 | 0x31A | float | home_z | CreateBadBall |
| 0xC6C | 0x31B | float | CHASE distance | CreateBadBall (from MW tag) |
| 0xC70 | 0x31C | float | HOME distance | CreateBadBall (from MW tag) |
| 0xC74 | 0x31D | byte | is_8ball flag | Ball_InitBattleMode |
| 0xC78 | 0x31E | float | spin_angle (radians) | Ball_ctor2 (byte 0 only? or InitPhysics?) |
| 0xC7C | 0x31F (799) | float | SPINDISTANCE (orbit radius) | CreateBadBall (from MW tag, or InitPhysics default?) |
| 0x164 | 0x59 | float | pos_x | Ball_Update (physics) |
| 0x168 | 0x5A | float | pos_y | Ball_Update |
| 0x16C | 0x5B | float | pos_z | Ball_Update |
| 0x284 | 0xA1 | float | radius | Ball_InitPhysicsDefaults (35.0) or SIZE tag |
GhidraMCP Notes
Ball_AI_ChaseNearestat 0x408390 was NOT in Ghidra's function table — had tocreate_functionfirst, thendecompile_functionworked.- The decompiled output uses
param_1[N](int* indexing, ×4 for byte offset) andparam_1[799](decimal index, ×4 = 0xC7C). __ftol2()calls in the decompilation are FPU register spills from float→int conversions used byMath_FastDistance2D— not actual function calls.unaff_EBX,unaff_EBP,unaff_ESI,unaff_EDIare register variables that Ghidra couldn't fully recover — they carry ball position coordinates through the distance calculations.
See Also
references/object-spawning-createbadball.md— CreateBadBall spawning analysis, Ball_InitPhysicsDefaults defaultsreferences/ball-vtable-player-vs-ai-path.md(in hamsterball-dll-modding skill) — vtable[4] dispatch for AI vs player balls
🔗 Related Documents
Ball [[53892486924051|collision system]] Analysis
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/collision-system-analysis.md)
Ball Collision System Analysis
Verdict: Collisions Are ASYMMETRIC Per-Event (Definitively Proven via Decompilation)
Each collision event generates a collision entry in only ONE ball's collision
list. The detecting ball processes the collision (push-apart, velocity exchange)
and writes velocity changes to BOTH balls' physics objects — but only the
detecting ball gets a collision entry. The other ball does not receive an entry
for this event.
Both balls independently detect the same contact across separate Ball_Update
calls (which run sequentially within Scene_UpdateBallsAndState), but each
detection is a separate event at a different tick. This is why runtime dumps
show entries at different timestamps with unequal counts (e.g., 4 player
entries vs 7 eight-ball entries).
How the Collision System Works (Full Call Chain)
1. Scene_UpdateBallsAndState (0x41B540)
Iterates the ball list at Scene+0x29D4 and calls vtable[4] (Ball_Update)
for each ball sequentially:
while (ball = AthenaList_Next(scene+0x29D4)) {
Scene_SetCamera(scene, ball, 1);
(*ball->vtable[4])(); // Ball_Update
}
No ball-ball collision detection happens here. It just calls each ball's
update function. Collision detection happens INSIDE each ball's Ball_Update.
2. Ball_Update (0x405E00) — Collision Setup
Each ball's Ball_Update creates its own collision detection objects:
Phase A: Create SpatialTree node (for wall/floor collisions)
00406898: PUSH 0x20 ; allocate 32 bytes
0040689a: CALL operator_new
004068bc: CALL SpatialTree_ctor ; 0x463330 — vtable = 0x4D9038
004068c5: MOV [EAX+0x10], 0xa ; type = 10 (spatial)
004068cc: MOV EDX, [ESI+0x278] ; ball+0x278 = geometry data
004068d2: MOV [EAX+0xc], EDX ; node+0xc = geometry
004068db: MOV [ESP+0x1c], EAX ; save SpatialTree ptr → [ESP+0x1c]
If ball+0x80c > 0, the SpatialTree is added to the collision mesh:
004068e9: MOV ECX, [ESI+0x1a4] ; ECX = collision_mesh
004068f1: PUSH EAX ; push SpatialTree
004068f2: CALL [EDX+0x14] ; vtable[5] = CollisionMesh_AddTriangle
CollisionMesh_AddTriangle (0x456120) appends to collision_mesh+0x430:
void CollisionMesh_AddTriangle(void *this, int node) {
AthenaList_Append(this + 0x430, node);
*(void **)(node + 8) = this; // back-pointer to mesh
}
Phase B: Create CollisionNode (for ball-ball collisions)
004068f5: PUSH 0x14 ; allocate 20 bytes
004068f7: CALL operator_new
0040690f: MOV ECX, [ESI+0x14] ; ECX = Scene (ball+0x14)
00406912: ADD ECX, 0x29d4 ; ECX = Scene+0x29D4 (ball list)
00406918: PUSH ECX
0040691b: CALL CollisionNode_ctor ; 0x466CF0
0040692c: MOV [ESP+0x18], EAX ; save CollisionNode ptr → [ESP+0x18]
CollisionNode_ctor stores the ball list reference:
void CollisionNode_ctor(void *this, undefined4 param_1) {
CollisionNode_BaseInit(this, param_1);
// vtable = 0x4D9128
*(this + 0x0c) = 0x3dcccccd; // radius scale
*(this + 0x10) = 0x3dcccccd;
}
void CollisionNode_BaseInit(void *this, undefined4 param_1) {
*this = &vtable_0x4DA65C; // base vtable
*(this + 4) = param_1; // ← CollisionNode+0x04 = Scene+0x29D4 (ball list!)
}
If ball+0x324 == 0 (not in special state), the CollisionNode is also added
to the collision mesh via vtable[5].
Phase C: Run collision detection (vtable[1])
004069d5: MOV ECX, [ESI+0x1a4] ; ECX = collision_mesh (this)
004069e4: MOV EDX, [ECX] ; EDX = vtable
004069e7: CALL [EDX+0x4] ; vtable[1] = Ball_AdvancePositionOrCollision
Ball_AdvancePositionOrCollision (0x4564C0) calls vtable[7] internally,
which does the actual collision detection.
3. CollisionMesh vtable[7] (0x456890) — Collision Detection Engine
This function iterates all nodes in collision_mesh+0x430 (the SpatialTree
and CollisionNode added above) and tests each against the ball's movement:
void CollisionMesh_Detect(void *this, float *out_pos, float *in_pos,
float *in_dir, float *in_scale, ...) {
do {
Material_Init(&local_material); // zero 0x68-byte struct
// Iterate all nodes in +0x430 list
for each node in AthenaList(this+0x430):
node->vtable[2](this, aabb, &local_material); // collision test
if (local_material.type_field == 0) break; // no collision
*param_7 = 1; // collision flag
// Call resolution function
node->vtable[3](this, &result_pos, this, &in_dir, &local_material);
// Create entry and append to collision list
entry = operator_new(0x68);
Material_Init(entry);
Material_Copy(entry, &local_material); // copy all fields
AthenaList_Append(this + 0x18, entry); // ← ONLY to THIS mesh's list!
} while (iteration_count < this+0xc60);
}
Critical: AthenaList_Append(this + 0x18, entry) appends to only this
ball's collision list. There is no second AthenaList_Append call to the
other ball's list.
4. CollisionNode vtable[2] (0x467030) — Ball-Ball Collision Test
This is the function that tests THIS ball against ALL other balls:
void CollisionNode_Test(int *this, int collision_mesh, float *aabb, int material) {
int ball_count = AthenaList_GetSize(this[1]); // this+0x04 = ball list
for (int i = 0; i < ball_count; i++) {
int *other_ball = AthenaList_Get(this[1], i);
// Self-collision skip: compare mesh IDs
if (*(collision_mesh + 0x10) != *(other_ball->collision_mesh + 0x10)) {
// Get other ball position and radius
float ox = other_ball[0x164]; // pos X
float oy = other_ball[0x168]; // pos Y
float oz = other_ball[0x16c]; // pos Z
float r = other_ball[0x284]; // radius
// AABB overlap test
if (aabb_overlap(aabb, sphere(ox, oy, oz, r))) {
// Detailed collision test
char hit = this->vtable[4](aabb, ..., &collision_t);
if (hit && (material->type == 0 || collision_t < material->t)) {
material->t = collision_t;
material->subtype = 4; // +0x04 = 4
material->source_ref = this; // +0x64 = CollisionNode ptr
material->other_ball = other_ball; // +0x0C = other ball ptr
material->material_ptr = 0; // +0x10 = NULL
// +0x14 = contact point (XYZ)
// +0x20 = normal (XYZ, negated)
// +0x58 = previous point
}
}
}
}
}
Key: The +0x64 field (source_ref) is set to this — the CollisionNode
pointer. This CollisionNode was created by THIS ball's Ball_Update, so only
this ball will match the +0x64 check in the collision processing loop.
5. CollisionNode vtable[3] (0x467360) — Ball-Ball Collision Resolution
This function is called AFTER vtable[2] detects a collision. It finalizes the
entry and applies velocity changes to BOTH balls:
void CollisionNode_Resolve(int this, float *out_pos, int collision_mesh,
int dir, undefined4 *material) {
// Set entry type to BALL-BALL
*material = 1; // +0x00 = 1 (ball-ball type!)
// Compute reflection/deflection
float angle = asin(...);
material[0x0b] = 1.0 - 2.0 * angle * angle; // +0x2C
// Copy relative velocity into entry
material[0x0c] = current_vel.x; // +0x30 = rel_vel X
material[0x0d] = current_vel.y; // +0x34 = rel_vel Y
material[0x0e] = current_vel.z; // +0x38 = rel_vel Z
// Get other ball's collision mesh
int other_ball = material[3]; // +0x0C = other_ball
int other_mesh = *(other_ball + 0x1a4); // other_ball+0x1A4
// Write velocity to THIS ball's mesh
*(collision_mesh + 0xca4) = push_vel.x * factor_a;
*(collision_mesh + 0xca8) = push_vel.y * factor_a;
*(collision_mesh + 0xcac) = push_vel.z * factor_a;
// Write velocity to OTHER ball's mesh
*(other_mesh + 0xca4) = push_vel2.x * factor_b;
*(other_mesh + 0xca8) = push_vel2.y * factor_b;
*(other_mesh + 0xcac) = push_vel2.z * factor_b;
}
This is the critical finding: The resolution function writes velocity changes
to BOTH balls' collision meshes (+0xCA4/+0xCA8/+0xCAC), but only ONE entry
is created (in the detecting ball's list).
6. Ball_Update Collision Processing Loop
After vtable[1] returns, Ball_Update iterates the collision entries:
// Clear old entries from +0x430 list
collision_mesh->vtable[6](); // AthenaList_Free(+0x430)
// Iterate entries in +0x18 list (populated by vtable[1])
for each entry in AthenaList(collision_mesh + 0x18):
// Phase 1: Wall collision (type == 2)
if (entry->type == 2 && entry->source_ref == spatialtree_node) {
// Update camera focus point
}
// Phase 2: Ball-ball trajectory (type == 1, source_ref == spatialtree)
// DEAD CODE: ball-ball entries have source_ref == CollisionNode,
// never == SpatialTree. This never fires for ball-ball.
if (entry->type == 1 && entry->source_ref == spatialtree_node) {
Ball_ApplyTrajectory(ball);
// camera/sound stuff
}
// Phase 3: Ball-ball physics (type == 1, source_ref == collisionnode)
if (entry->type == 1 && entry->source_ref == collisionnode) {
other_ball = entry->other_ball; // +0x0C
// Push-apart: compute distance, apply separation
// Velocity exchange: read both balls' positions
// Sound: play collision sound if rel_vel > threshold
// Camera: call vtable[8] for camera focus
}
Disasm Verification of +0x64 Check
The decompiler incorrectly mapped unaff_EBP and unaff_ESI to the collision
checks. The actual disassembly shows:
; EBP = current collision entry (piVar16)
; ESI = ball pointer (set at 00405e20: MOV ESI,ECX)
; [ESP+0x1c] = SpatialTree node (set at 004068db)
; [ESP+0x18] = CollisionNode (set at 0040692c)
; Phase 1 (type == 2, wall):
00406b80: CMP [EBP], 0x2 ; entry->type == 2?
00406b8a: CMP [EBP+0x64], EDX ; entry+0x64 == [ESP+0x1c] (SpatialTree)?
; Phase 2 (type == 1, trajectory — DEAD CODE for ball-ball):
00406bd3: CMP [EBP], 0x1 ; entry->type == 1?
00406be1: CMP [EBP+0x64], EDX ; entry+0x64 == [ESP+0x1c] (SpatialTree)?
; Phase 3 (type == 1, physics):
00406c79: CMP [EBP], 0x1 ; entry->type == 1?
00406c87: CMP [EBP+0x64], ECX ; entry+0x64 == [ESP+0x18] (CollisionNode)?
00406c90: MOV EDI, [EBP+0xc] ; other_ball = entry+0x0C
Ghidra decompiler error: The decompiler mapped [ESP+0x18] (loaded into
ECX at 0x00406c83) to unaff_ESI. This is wrong — ESI holds the ball pointer
(set at MOV ESI,ECX in the prologue), but ECX at 0x00406c83 is loaded from
[ESP+0x18] (the CollisionNode). The actual comparison is entry+0x64 == CollisionNode, not entry+0x64 == ball.
Collision Entry Struct (0x68 bytes)
| Offset | Type | Name | Set By | Evidence |
|---|---|---|---|---|
| +0x00 | int | type | vtable[3] (0x467360): *material = 1 |
0x00000001 for ball-ball, 0x00000005 for floor (set in Ball_AdvancePositionOrCollision: *puVar7 = 5) |
| +0x04 | int | subtype | vtable[2] (0x467030): *(param_4+4) = 4 |
Always 4 for ball-ball, 1 for wall, 0 for floor |
| +0x08 | float | collision_t | vtable[2]: *(param_4+8) = fStack_8c |
Distance ratio along movement ray |
| +0x0C | ptr | other_ball | vtable[2]: *(param_4+0xc) = iVar3 (ball from list) |
Verified in dump: points to other ball's address |
| +0x10 | ptr | material_ptr | vtable[2]: *(param_4+0x10) = 0 |
NULL for ball-ball, set for wall entries |
| +0x14 | float[3] | contact_point | vtable[2]: Vec3_CopyUnlessEqual(param_4+0x14, ...) |
World-space XYZ of contact |
| +0x20 | float[3] | normal | vtable[2]: Vec3_CopyUnlessEqual(param_4+0x20, ...) |
Contact normal, negated (points away from other ball) |
| +0x2C | float | deflection | vtable[3]: material[0x0b] = fVar11 |
Angle-based deflection factor |
| +0x30 | float[3] | rel_vel | vtable[3]: material[0xc..0xe] |
Relative velocity at contact point |
| +0x3C–0x50 | — | unused | Material_Init zeroes | Always zero for ball-ball |
| +0x54 | float | penetration | Not set for ball-ball | Only used for type==5 (floor) |
| +0x58 | float[3] | prev_point | vtable[2]: Vec3_CopyUnlessEqual(param_4+0x58, ...) |
Previous contact point |
| +0x64 | ptr | source_ref | vtable[2]: *(param_4+100) = param_1 (CollisionNode) |
Ownership token: which CollisionNode created this entry |
| +0x68–0x7C | — | trailing data | Not part of Material struct (0x68 bytes) | Appears in dump as adjacent heap data |
Note: The entry struct is 0x68 bytes (allocated by operator_new(0x68)),
NOT 0x80 bytes. The dump shows 0x80 bytes because the logging code reads
past the allocation boundary. Fields +0x68–0x7C are adjacent heap data, not
part of the entry struct.
CollisionMesh vtable
Vtable at 0x4D8E10 (set by CollisionMesh_ctor at 0x456D80):
| Slot | Offset | Address | Function |
|---|---|---|---|
| 0 | 0x00 | 0x456870 | Mesh dtor |
| 1 | 0x04 | 0x4564C0 | Ball_AdvancePositionOrCollision — move + detect collisions |
| 2 | 0x08 | 0x456280 | Unknown |
| 3 | 0x0C | 0x456CD0 | Ball_InitBattleMode — battle mode setup |
| 4 | 0x10 | 0x456110 | Unknown |
| 5 | 0x14 | 0x456120 | CollisionMesh_AddTriangle — add node to +0x430 list |
| 6 | 0x18 | 0x456140 | AthenaList_Free(+0x430) — clear node list |
| 7 | 0x1C | 0x456890 | Collision detection engine — test nodes, create entries |
| 8 | 0x20 | 0x457A20 | Unknown |
CollisionNode vtable
Vtable at 0x4D9128 (set by CollisionNode_ctor at 0x466CF0):
| Slot | Offset | Address | Function |
|---|---|---|---|
| 0 | 0x00 | 0x466D50 | dtor |
| 1 | 0x04 | 0x466CD0 | Unknown |
| 2 | 0x08 | 0x467030 | Ball-ball collision test — iterate ball list, AABB test |
| 3 | 0x0C | 0x467360 | Ball-ball collision resolve — set type=1, exchange velocities |
| 4 | 0x10 | 0x466D70 | Sphere-ray intersection test |
| 5 | 0x14 | 0x466F50 | Unknown |
SpatialTree vtable
Vtable at 0x4D9038 (set by SpatialTree_ctor at 0x463330):
| Slot | Offset | Address | Function |
|---|---|---|---|
| 2 | 0x08 | 0x463880 | SpatialTree_ForEach — iterate children, call their vtable[8] |
| 6 | 0x18 | 0x463500 | Unknown (called at 0x00406895 via vtable) |
Collision List Location
| Offset from Ball | Offset from CollisionMesh | Field |
|---|---|---|
| Ball + 0x1A4 | — | collision_mesh_ptr (pointer to CollisionMesh) |
| — | collision_mesh + 0x10 | mesh_id (unique ID for self-collision skip) |
| — | collision_mesh + 0x18 | entry_list (AthenaList of collision entries) |
| — | collision_mesh + 0x1C | entry_count (AthenaList count) |
| — | collision_mesh + 0x424 | entry_arr (AthenaList data pointer) |
| — | collision_mesh + 0x430 | node_list (AthenaList of SpatialTree/CollisionNode) |
| — | collision_mesh + 0xC60 | max_iterations (collision detection loop limit) |
| — | collision_mesh + 0xC74 | collision_time (accumulated collision time) |
| — | collision_mesh + 0xCA4 | velocity (XYZ float, written by resolution) |
| — | collision_mesh + 0xCA8 | velocity_y |
| — | collision_mesh + 0xCAC | velocity_z |
Note: In the decompiler, param_1[0x69] means *(int*)(ball + 0x69*4) =
*(int*)(ball + 0x1A4). The memory note "Ball + 0x69 (DWORD)" was using a
different indexing convention. The correct offset is ball + 0x1A4 (byte offset).
Stale Entry Problem
The AthenaList at collision_mesh+0x18 is NOT cleared between frames for
entries that persist. Old collision entries remain with stale +0x0C pointers.
These stale entries have invalid playerID values at other_ball + 0x18.
Fix
int other_id = *(int*)((char*)other_ball + 0x18);
if (other_id != 0 && other_id != -1) continue; // skip stale
Modding Implications
1. Hook ALL Balls, Not Just the Player
Since each ball creates entries only in its own list, hooking only the player's
Ball_Update misses ~64% of collision events (based on the 4:7 ratio observed
in the dump). Hook 0x405E00 and process entries for every ball:
void __fastcall Hooked_BallUpdate(Ball* ball, void* edx) {
Original_BallUpdate(ball, edx);
CollisionMesh* mesh = *(CollisionMesh**)((char*)ball + 0x1A4);
int count = *(int*)((char*)mesh + 0x1C);
if (count <= 0) return;
void** entries = *(void***)((char*)mesh + 0x424);
if (!entries || !*entries) return;
for (int i = 0; i < count; i++) {
DWORD* e = (DWORD*)entries[0][i];
if (!e || IsBadReadPtr(e, 0x68)) continue;
if (e[0] != 1) continue; // ball-ball only (type at +0x00)
DWORD other_ptr = e[3]; // +0x0C
if (other_ptr <= 0x10000) continue;
if (IsBadReadPtr((void*)other_ptr, 0x20)) continue;
int other_id = *(int*)((char*)other_ptr + 0x18);
if (other_id != 0 && other_id != -1) continue;
mod->onBallBump(ball, (Ball*)other_ptr);
}
}
2. Fire onBallBump for Both Participants
Since only the detecting ball has the entry, the other ball won't see this
collision event. For full coverage (e.g., if you need both balls to react),
fire the callback for both:
mod->onBallBump(ball, (Ball*)other_ptr); // detecting ball
mod->onBallBump((Ball*)other_ptr, ball); // other ball (no entry, but was hit)
3. Contact Deduplication
The collision list persists entries across frames while balls remain in contact.
Use a per-ball touching set to fire onBallBump only on new contacts:
static DWORD touching[MAX_BALLS][16] = {0};
4. Velocity Fields Are Shared
The resolution function writes to BOTH balls' velocity fields
(collision_mesh+0xCA4/+0xCA8/+0xCAC). If you modify one ball's velocity
after collision, the other ball may have already been affected. Read velocities
AFTER Original_BallUpdate returns to see post-collision values.
5. Phase 2 (Ball_ApplyTrajectory) Is Dead Code
The if (type==1 && source_ref==SpatialTree) check at 0x00406be1 never fires
for ball-ball entries because they have source_ref == CollisionNode. If you
need trajectory effects on ball-ball collisions, you must add them yourself
in the hook — the game's built-in trajectory code only runs for wall collisions.
6. Asymmetry Affects Detection Frequency
The 8-ball receives ~1.8x more collision entries than the player because:
- The 8-ball's
Ball_Updateruns after the player's in the iteration order - By the time the 8-ball processes, the player may have already pushed it
- The 8-ball then detects the ongoing contact and creates its own entry
- The player, on its next update, may find the balls have separated
This means collision events are biased toward whichever ball runs second in
the Scene_UpdateBallsAndState iteration.
Summary of Decompilation Chain
Scene_UpdateBallsAndState (0x41B540)
└→ Ball_Update (0x405E00) [for each ball]
├→ Create SpatialTree node → [ESP+0x1c]
├→ Create CollisionNode (with Scene+0x29D4 ball list) → [ESP+0x18]
├→ CollisionMesh_AddTriangle (vtable[5]) — add both nodes to +0x430
├→ Ball_AdvancePositionOrCollision (vtable[1] = 0x4564C0)
│ └→ CollisionMesh_Detect (vtable[7] = 0x456890)
│ ├→ For each node in +0x430:
│ │ ├→ SpatialTree_ForEach (vtable[2] = 0x463880) — wall/floor test
│ │ │ └→ Recursively test child nodes (CollisionFace)
│ │ └→ CollisionNode_Test (vtable[2] = 0x467030) — ball-ball test
│ │ └→ For each ball in Scene+0x29D4:
│ │ ├→ Skip self (compare mesh+0x10 IDs)
│ │ ├→ AABB overlap test
│ │ └→ On hit: set entry fields (+0x04, +0x08, +0x0C, +0x64)
│ ├→ If collision detected:
│ │ └→ CollisionNode_Resolve (vtable[3] = 0x467360)
│ │ ├→ Set entry type = 1 (+0x00)
│ │ ├→ Set rel_vel (+0x30)
│ │ ├→ Write velocity to THIS mesh (+0xCA4/+0xCA8/+0xCAC)
│ │ └→ Write velocity to OTHER mesh (+0xCA4/+0xCA8/+0xCAC)
│ └→ Create 0x68-byte entry, Material_Copy, append to +0x18
│ ⚠ ONLY to THIS ball's list — NOT the other ball's list
├→ AthenaList_Free(+0x430) (vtable[6]) — clear node list
└→ Iterate entries in +0x18:
├→ Phase 1: type==2 && +0x64==SpatialTree → wall (camera focus)
├→ Phase 2: type==1 && +0x64==SpatialTree → DEAD CODE for ball-ball
└→ Phase 3: type==1 && +0x64==CollisionNode → push-apart physics
Document created from decompiled source analysis of Hamsterball.exe via GhidraMCP.
All function addresses, vtable layouts, and field offsets verified through
disassembly cross-referencing. The Ghidra decompiler's unaff_EBP/unaff_ESI
mapping was corrected against raw x86 disassembly.
🔗 Related Documents
Ball Break Mod
Ball Break Mod
Press X to shatter your ball and respawn at the nearest checkpoint.
How It Works
- A background thread polls DIK_X (0x2D) every 16ms
- On rising-edge keypress, checks gates:
- Countdown finished (scene+0x3A4C == 1)
- Race not ended (App+0x5D6 == 0)
- Player flag2 clear (App+0x5D5 == 0)
- Ball not already shattered (ball+0x2E8 == 0)
- Calls
Ball_Shatter(0x408D70) — the game's own "ball breaks into 3 pieces" function - Sets
ball+0x2E8=1(shattered flag) - Next frame,
Scene_UpdateBallsAndState(0x41B540) detects the shattered flag and callsBall_FindClosestRespawnPoint(0x405190) → teleports ball to nearest respawn point, clears velocity, starts fall animation
Reverse Engineering Details
| Function | Address | Convention | Purpose |
|---|---|---|---|
Ball_Shatter |
0x408D70 | __thiscall(ball, Vec3List*) |
Breaks ball into 3 debris pieces, plays sound |
Ball_Shrink |
0x402200 | __fastcall(ball) |
odd race E:SHRINK: ball+0xC4C=1, radius to 13.0 |
Ball_Grow |
0x402270 | __fastcall(ball) |
Sets ball+0xC4C=0, radius to 26.0 (normal) |
Ball_FindClosestRespawnPoint |
0x405190 | __fastcall(ball) |
Finds nearest respawn, teleports, clears velocity |
Scene_UpdateBallsAndState |
0x41B540 | __fastcall(scene) |
Per-frame: checks ball+0x2E8, triggers respawn |
Ball Struct Offsets
| Offset | Type | Field |
|---|---|---|
| 0x014 | DWORD | Board pointer |
| 0x018 | int | Player index (0=P1, -1=AI) |
| 0x164 | float | Position X |
| 0x168 | float | Position Y |
| 0x16C | float | Position Z |
| 0x2E8 | byte | Is shattered (set by Shatter) |
| 0x2F9 | byte | Is falling (set by FindClosestRespawnPoint) |
| 0x300 | DWORD | Respawn timer (set to 150 = 0x96) |
| 0x324 | byte | Is invincible (if 1, destroyed instead of respawned) |
Installation
- Back up your original
bass.dll(rename tobass_real.dll) - Copy this mod's
bass.dllinto the Hamsterball game folder - Launch the game
- Press X during a race to break and respawn
Build
i686-w64-mingw32-gcc -shared -o bass.dll ball_break.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Crash-tested: process survives 40s on Wine/Xvfb (DLL loads cleanly, no stack corruption).
🔗 Related Documents
Ball Color Cycle Mod
Ball Color Cycle Mod
Info
- File:
bass.dll(proxy) - Controls: Press F2 in-game to cycle through 10 colors
- Android-safe: No IAT hooks, no code caves, no D3D API calls from thread
Colors (10 total)
White → Orange → Blue → Green → Pink → Yellow → Purple → Cyan → Red → Dark Orange → (loops back)
What it does
Tints player 1's hamster ball to a selected color. Sets the ball's material
diffuse/ambient/emissive RGBA values and activates the game's material override
system (gfx+0x7C0) so the 3D sphere mesh uses the custom color.
v3 Crash Fix
v2 crashed after finishing a race because gfx+0x7C0 was set to ball+0x208
but never cleared. When the ball was destroyed at race end, the pointer became
dangling — the render thread read freed memory → crash.
Fix: When no ball is found (scene transition/race end), gfx+0x7C0 is set
to 0 (NULL), making the game use its default material. The pointer is re-set
to the new ball's render context on the next frame when the ball is found again.
Build
i686-w64-mingw32-gcc -shared -o bass.dll ball_color_cycle.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Installation
- Rename original
bass.dlltobass_real.dll - Copy mod
bass.dllto game folder - On Android/Wine: set DLL override to
nativeforbass.dll
🔗 Related Documents
Ball Ground Detection
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/ball-ground-detection.md)
Ball Ground Detection — Verified Offsets
TL;DR
| Offset | Name | Reliable for ground check? | Notes |
|---|---|---|---|
Ball+0x281 |
unused_init_flag |
❌ NO | DEAD: set by ctor, NEVER read by any function |
Ball+0xC4C |
is_shrunk |
⚠ Partial | Only set by odd race E:SHRINK/E:GROW, NOT ground contact |
Ball+0x2E9 |
impact_shatter |
❌ BROKEN | Sticky flag, never cleared in Ball_Update. DO NOT USE. |
Ball+0x260 |
is_airborne |
⚠ Partial | Set by speed/friction thresholds, not true ground contact |
For jump mods: Use a cooldown timer (60 frames). No ground flag needed.
For mods needing true ground contact: Use the engine's raycast (Mesh_FindClosestCollision) from a background thread.
⚠ CRITICAL: Ball+0x2E9 is NOT a ground-contact flag
Previous versions of multiple docs (BALL_OBJECT.md, BALL_UPDATE_DECOMP.md, etc.) labeled Ball+0x2E9 as:
on_ramp(slope/ramp flag)on_surface(ground-contact flag)flag2(reset to 0 each frame)is_teleporting
All of these labels are WRONG.
What 0x2E9 actually is
Ball+0x2E9 is a limit/trajectory flag:
- Set to 1 by:
E:LIMITarena events (finish line reached)- Type-5 floor collision with deep penetration (
piVar16[0x15] > _DAT_004cf310)
- Cleared to 0 by
Ball_FindClosestRespawnPoint(at 0x00405262:MOV byte [ESI+0x2E9],0) andBall_ctor2
⚠ CORRECTION: Previous "sticky flag" claim was wrong
Previous versions of this doc claimed Ball_FindClosestRespawnPoint's clear was int* arithmetic writing to +0xBA4 instead of +0x2E9, making the flag "sticky." This was wrong. The actual disassembly at 0x00405262 is:
00405262: C6 86 E9 02 00 00 00 MOV byte [ESI+0x2E9], 0
This is a direct byte write to offset 0x2E9 via the ModRM 9E encoding ([ESI+disp32]). The Ghidra decompilation's param_1 + 0x2e9 uses param_1 declared as int (not int*), so the arithmetic IS byte offset 0x2E9. The flag IS properly cleared on respawn.
However, the flag is still not cleared per-frame within Ball_Update — it persists between frames within a single life. It's only cleared on respawn/teleport, not every physics tick.
Real-world bug caused by this
The jump_mod used Ball+0x2E9 as a ground check, reading it at the end of Ball_Update. Symptoms:
- Can't jump on flat ground (flag hasn't been set yet from a deep collision)
- CAN jump midair (flag is stuck at 1 from a previous frame's deep collision)
Fix: replaced with a 60-frame cooldown timer after each jump.
What DOES work for ground detection
Option 1: Cooldown timer (simplest, for jump mods)
After applying jump velocity, set a 60-frame counter. While >0, block jumping.
- Jump velocity 500, gravity ~15/frame
- Apex at ~33 frames, landing at ~66 frames
- 60-frame cooldown covers most of the arc
- Pros: Simple, no external calls, works on ramps/slopes
- Cons: Ball can technically double-jump in the last ~6 frames before landing (frame 60-66). Can increase to 80 to be safe.
Option 2: Engine raycast (for true ground contact)
Call Mesh_FindClosestCollision from a background thread (NOT from a code cave):
// Ball_FindMeshCollision @ 0x00403980
// thiscall: this=Ball*, param_1=Vec3* out
// Reads ball's CollisionMesh (ball+0x1A4), uses ball position + gravity
// direction (CollisionMesh+0xC8C), writes closest hit point to out.
typedef void (__attribute__((thiscall)) *BallFindMeshCollision_t)(
void* ball, Vec3* out);
// Call from background thread:
Vec3 out = {0};
find_collision(ball_ptr, &out);
// Compare out to ball position — if close, ball is grounded
⚠ DO NOT call from a code cave — calling C functions from hand-assembled code caves inside Ball_Update corrupts stack/FPU/SEH state and crashes the game. Use Pattern 4 (volatile flag + polling thread) from the modding skill.
Key offsets for raycast
| Offset | Name | Type | Purpose |
|---|---|---|---|
Ball+0x164 |
position | Vec3 | Current ball position (X/Y/Z) |
Ball+0x1A4 |
collision_mesh | ptr | CollisionMesh pointer |
Ball+0x284 |
radius | float | Ball radius (~35.0) |
CollisionMesh+0xC8C |
gravity_dir | Vec3 | Gravity direction (normalized) |
Scene+0x8B0 |
collision_level | ptr | Level CollisionMesh (for Mesh_FindClosestCollision) |
⚠ unused_init_flag (Ball+0x281) is DEAD CODE
- Documented as
unused_init_flag(formerly is_falling) in BALL_OBJECT.md - NOT read during
Ball_Update(0x405E00) physics tick - Legacy/init flag only
- Setting it to 0 in water mods works as a side effect (clears a stale state), but it does NOT reflect current ground contact
is_shrunk (Ball+0xC4C) is Odd Race shrink mechanic only
- Set by
Ball_Shrink(0x402200) during odd race E:SHRINK collision event - Cleared by
Ball_Grow(0x402270) during odd race E:GROW collision event - Does NOT track per-frame ground contact — ball can be midair (from a jump) with is_shrunk=0
- NOT related to falling off edges — Ball_Shatter (0x408D70) handles falling and never writes to 0xC4C
🔗 Related Documents
Ball Object
Ball Object — Modding Reference
Parameter of
Ball_Update(0x405E00)
Total size: 0xC98 bytes (3224 bytes)
Vtable: 0x4CF3A0 (9 method pointers)
Constructor:Ball_ctor2(0x4039E0)
Verified via: live GhidraMCP decompilation of 22 Ball functions
1. Quick Anatomy
The Ball is the player (or AI) physics object. Every frame GameLoop → Scene_Update → Ball_Update ticks it through a 23-phase pipeline (see §3). Modders patch mid-pipeline to alter physics, rendering, or input without breaking collision coherence.
Canonical entry points
Scene+0x21E→App→App+0x5DC→ currentScene→ iterate ballsBall+0x14→ back-pointer to owningScene(all balls in a scene share it)Ball+0x1A4→CollisionMesh(separate 0xCB0-byte physics body)
2. Verified Field Layout
Offsets below are byte addresses (not int[0xNN] array indices). All types are confirmed by Ball_ctor2 writes and Ball_Update reads.
| Offset | Type | Name | Verified By | Modder Notes |
|---|---|---|---|---|
| 0x000 | uint32_t |
vtable | ctor2 | GameObject vtable; 9 methods. Do NOT overwrite. |
| 0x004 | uint8_t[2] |
field_04 | ctor2 | Reserved |
| 0x008 | int32_t |
collision_result | ctor2 | Bitflags from collision system |
| 0x00C | int32_t |
string_timer | ctor2, Update | Countdown; frees display_string at 0 |
| 0x010 | App* |
app | ctor2 | Back-pointer to global App |
| 0x014 | Scene* |
scene | ctor2 | Back-pointer to owning Scene |
| 0x018 | int32_t |
player_index | ctor2 | -1 = AI, 0 = Player 1, 1 = Player 2 |
| 0x01C | uint32_t |
render_callback | ctor2 | Vtable for render/audio callbacks |
| 0x020 | uint8_t[0xEC] |
UITimer | ctor2 | UITimer sub-object (236 bytes) |
| 0x108 | Timer |
timer | ctor2 | Timer_Init target |
| 0x150 | float |
accumulated_time | Update | Delta-time accumulator for physics sub-steps |
| 0x154 | int32_t |
rng_seed | ctor2 | RNG_Rand seed |
| 0x158 | float |
prev_pos_x | Update | Position previous frame |
| 0x15C | float |
prev_pos_y | Update | " |
| 0x160 | float |
prev_pos_z | Update | " |
| 0x164 | float |
pos_x | ctor2, Update | Current position — primary mod target |
| 0x168 | float |
pos_y | ctor2, Update | " |
| 0x16C | float |
pos_z | ctor2, Update | " |
| 0x170 | float |
vel_x | ctor2, Update | Current velocity — write AFTER collision phase |
| 0x174 | float |
vel_y | ctor2, Update | " |
| 0x178 | float |
vel_z | ctor2, Update | " |
| 0x17C | float |
display_vel_x | Update | Display-interpolated velocity |
| 0x180 | float |
display_vel_y | Update | " |
| 0x184 | float |
display_vel_z | Update | " |
| 0x188 | float |
max_speed | ctor2, Update | Default 5000.0f. Mod for speed limits. |
| 0x18C | float |
speed_scale | ctor2, Update | Default 1.0f. Multiplier on input force. |
| 0x190 | uint8_t[12] |
pad_190 | ctor2 | |
| 0x19C | uint8_t |
field_19c | ctor2 | Set 0 |
| 0x1A0 | float |
max_speed_cap | ctor2 | 1.0f hard cap |
| 0x1A4 | CollisionMesh* |
physics_body | ctor2 | Separate physics object — see COLLISIONMESH_OBJECT_MODDING.md |
| 0x1A8 | float[3] |
gravity | ctor2, Update | Default (0, 1.0, 0). Flip Y sign for inverted gravity. |
| 0x1B4 | uint8_t[4] |
pad_1b4 | ||
| 0x1B8 | RenderContext |
render_ctx_1 | ctor2 | First render context |
| 0x1C8 | float |
render_alpha | ctor2 | 0.75f |
| 0x1CC | uint8_t[0x3C] |
pad_1cc | ||
| 0x208 | RenderContext |
render_ctx_2 | ctor2 | Second render context |
| 0x20C | float |
color_a | ctor2 | RGBA alpha |
| 0x210 | float |
color_r | ctor2 | " |
| 0x214 | float |
color_g | ctor2 | " |
| 0x218 | float |
color_b | ctor2 | " |
| 0x21C | uint8_t[0x20] |
pad_21c | ||
| 0x23C | float |
tint_x | ctor2 | 1.0f |
| 0x240 | float |
tint_y | ctor2 | 1.0f |
| 0x244 | float |
tint_z | ctor2 | 1.0f |
| 0x248 | float |
tint_w | ctor2 | 1.0f |
| 0x24C | uint8_t[8] |
pad_24c | ||
| 0x254 | bool |
uses_alpha | ctor2 | color_a != 1.0f |
| 0x255 | uint8_t[0xB] |
pad_255 | ||
| 0x260 | uint8_t |
boost_flag | ctor2 | Set 0 |
| 0x261 | uint8_t[3] |
pad_261 | ||
| 0x264 | ArenaBoard |
rumble_timer1 | ctor2 | ToggleTimer_Init target (20 bytes) |
| 0x278 | float |
gravity_scale | ctor2, Update | Default 0.1f. Scale gravity strength. |
| 0x27C | uint32_t |
field_27c | ctor2 | 0 |
| 0x280 | uint8_t |
field_280 | ctor2 | 0 |
| 0x281 | bool |
unused_init_flag | ctor2, Update | 1 in ctor; DEAD: never read by any function |
| 0x282 | uint8_t[2] |
pad_282 | ||
| 0x284 | float |
radius | ctor2, Update | Default 27.0f. Hit-box size. |
| 0x288 | uint32_t |
field_288 | ctor2 | 0 |
| 0x28C | uint8_t |
field_28c | ctor2 | 0 |
| 0x28D | uint8_t[3] |
pad_28d | ||
| 0x290 | ArenaBoard |
rumble_timer2 | ctor2 | Second ArenaBoard timer |
| 0x2A4 | float |
field_2a4 | ctor2 | 5.0f |
| 0x2A8 | Vec3 |
speed_modifier | ctor2 | Vec3_Init target |
| 0x2B4 | uint8_t[4] |
pad_2b4 | ||
| 0x2B8 | float |
accel_x | Update | Frame-cleared acceleration |
| 0x2BC | float |
accel_y | Update | " |
| 0x2C0 | float |
accel_z | Update | " |
| 0x2C4 | uint8_t |
field_2c4 | Update | |
| 0x2C8 | uint8_t[4] |
pad_2c8 | ||
| 0x2CC | uint8_t |
field_2cc | ctor2 | 0 |
| 0x2CD | uint8_t[7] |
pad_2cd | ||
| 0x2D4 | uint8_t |
field_2d4 | ctor2 | 0 |
| 0x2D5 | uint8_t |
field_2d5 | ctor2, Update | 0 |
| 0x2D6 | uint8_t[2] |
pad_2d6 | ||
| 0x2D8 | uint32_t |
field_2d8 | ctor2 | 0 |
| 0x2DC | float |
lgp_x | Update | Last Grounded Position (LGP) — snapshot at last type-2 ground collision |
| 0x2E0 | float |
lgp_y | Update | " |
| 0x2E4 | float |
lgp_z | Update | " |
| 0x2E8 | bool |
event_flag | ctor2, Update | Checkpoint hit event |
| 0x2E9 | bool |
impact_shatter | ctor2 | ⚠ NOT on_ramp! Sticky limit/trajectory flag (E:LIMIT + type-5 collision). Never cleared within Ball_Update. |
| 0x2EC | uint32_t |
field_2ec | ctor2 | Collision counter |
| 0x2F0 | uint32_t |
field_2f0 | ctor2 | 0 |
| 0x2F4 | uint32_t |
field_2f4 | ctor2 | 0 |
| 0x2F8 | uint8_t |
field_2f8 | Update | |
| 0x2F9 | uint8_t |
field_2f9 | ctor2, Update | |
| 0x2FC | float |
timer_bf | ctor2, Update | Default 1.0f |
| 0x300 | uint32_t |
field_300 | Update | Set 0 |
| 0x304 | float |
saved_pos_x | Update | Saved position copy |
| 0x308 | float |
saved_pos_y | Update | " |
| 0x30C | float |
saved_pos_z | Update | " |
| 0x310 | uint8_t |
field_310 | ctor2 | 1 |
| 0x311 | uint8_t[3] |
pad_311 | ||
| 0x314 | uint32_t |
field_314 | Update | Decayed each frame |
| 0x318 | uint8_t[4] |
pad_318 | ||
| 0x31C | bool |
field_31c | ctor2 | 0 |
| 0x31D | uint8_t |
field_31d | ctor2 | 0 |
| 0x31E | bool |
field_31e | ctor2 | 0 |
| 0x31F | uint8_t |
field_31f | ctor2 | |
| 0x320 | uint8_t[8] |
pad_320 | ||
| 0x328 | int32_t |
field_328 | ctor2 | -1 |
| 0x32C | AthenaList |
collision_list | ctor2 | AthenaList_Init target |
| 0x744 | float |
field_744 | ctor2 | 0 |
| 0x748 | float |
field_748 | ctor2 | 0 |
| 0x74C | float |
cam_offset_1 | Update | Camera offset |
| 0x750 | float |
cam_offset_2 | Update | Camera offset |
| 0x754 | uint8_t[0x10] |
pad_754 | ||
| 0x764 | float |
cam_follow_factor | ctor2 | Camera lerp factor 1.0f |
| 0x768 | bool |
cam_active | ctor2, Update | 1 = camera follow on |
| 0x769 | uint8_t |
pad_769 | ctor2 | 0 |
| 0x76A | uint8_t[0x5E] |
pad_76a | ||
| 0x7C8 | float[16] |
matrix_1 | ctor2, Update | 4×4 transform matrix |
| 0x808 | float[16] |
matrix_2 | ctor2, Update | 4×4 transform matrix |
| 0x848 | uint8_t[0x380] |
pad_848 | ||
| 0xC28 | char* |
display_string | ctor2, Update | Allocated string; freed when timer expires |
| 0xC2C | uint8_t |
pad_c2c | ctor2 | |
| 0xC2D | uint8_t[0xB] |
pad_c2d | ||
| 0xC38 | int32_t |
field_c38 | ctor2 | -1 |
| 0xC3C | bool |
teleport_active | ctor2, Update | Teleport pending flag |
| 0xC3D | uint8_t[3] |
pad_c3d | ||
| 0xC40 | float |
teleport_x | Update | Teleport destination |
| 0xC44 | float |
teleport_y | Update | " |
| 0xC48 | float |
teleport_z | Update | " |
| 0xC4C | uint8_t |
field_c4c | ctor2, Update | 0 |
| 0xC4D | uint8_t[3] |
pad_c4d | ||
| 0xC50 | float |
field_c50 | Update | Decayed each frame (*= 0.998 approx) |
| 0xC54 | uint32_t |
field_c54 | Update | |
| 0xC58 | uint8_t |
field_c58 | Update | 0 |
| 0xC59 | uint8_t[3] |
pad_c59 | ||
| 0xC5C | uint32_t |
field_c5c | Update | Decayed each frame |
3. Ball_Update 23-Phase Execution Order
The function at 0x405E00 runs these phases every tick. Hook at the right phase:
| Phase | Offset Range | What Happens | Safe to Patch? |
|---|---|---|---|
| 1 | 0xC50, 0xC5C | Decay timers (multiplicative fade) | ⚠️ Post-decay OK |
| 2 | 0x2F0, 0x2F4 | Decay collision counters | ⚠️ Post-decay OK |
| 3 | 0x314 | field_314 decay |
⚠️ Post-decay OK |
| 4 | 0x2FC | timer_bf countdown |
⚠️ Post-decay OK |
| 5 | 0xC54 | Check flag, call vtable+0x18 | ❌ Skip if event logic |
| 6 | 0x150 | accumulated_time += deltaTime |
❌ Pre-physics |
| 7 | 0x154 | rng_seed = RNG_Rand() |
❌ Random state |
| 8 | 0x158-0x16C | Save prev_pos = pos |
❌ Copy state |
| 9 | 0x300 | field_300 = 0 (reset) |
❌ Pre-collision |
| 10 | 0x2A0 | speed_modifier decay |
⚠️ Post OK |
| 11 | 0x29C | field_29C = 1.0f (reset) |
❌ Resets your changes |
| 12 | 0xC0 | piVar16 = param_1 + 0xC0 (trail setup) |
❌ Trail state |
| 13 | 0xC28 | display_string check & free |
❌ String lifetime |
| 14 | 0x14C | field_14C = 0 (reset) |
❌ |
| 15 | 0x768 | Camera follow check | ✅ Hook here for camera hacks |
| 16 | 0x310 | field_310 check |
❌ |
| 17 | 0x1A4 | CollisionMesh gravity reflect | ❌ Physics internals |
| 18 | 0x1D6 | Teleport apply (teleport_active → pos) |
❌ Use teleport_* fields instead |
| 19 | 0x164-0x16C | Velocity integration → position | ⚠️ Best hook for pos overrides |
| 20 | 0x170-0x178 | Velocity clamping & friction | ⚠️ Best hook for speed hacks |
| 21 | 0x188 | Max speed enforcement | ❌ Overwritten |
| 22 | 0x2B8-0x2C0 | Acceleration clear | ❌ Resets to 0 |
| 23 | 0x2DC-0x2E4 | Checkpoint update | ❌ |
Best hook phases for common mods:
- Teleport / position override: Phase 19 (after integration, before friction)
- Speed boost: Phase 20 (after velocity integration, before clamping)
- Invincibility / no-clipping: Phase 17 (skip collision by zeroing collision results)
- Camera control: Phase 15 (before camera logic reads
pos) - Gravity hack: Phase 17 (overwrite
gravityat +0x1A8 before reflect)
4. Callable Functions (VTable + Standalone)
Vtable Methods (offset 0x000 → 0x4CF3A0)
| VTable Offset | Function | Called From |
|---|---|---|
| +0x00 | GameObject_sub2_dtor |
Destructor chain |
| +0x04 | UITimer_Ctor |
Ball_ctor2 |
| +0x08 | ? | |
| +0x0C | ? | |
| +0x10 | ? | |
| +0x14 | ? | |
| +0x18 | Ball_Render (indirect) |
Scene_Render via callback |
| +0x1C | ? | |
| +0x20 | Ball_Update trigger |
Called by scene tick |
Standalone Ball Functions
| Address | Name | Signature | Modder Use |
|---|---|---|---|
| 0x4015B0 | Ball_SetupCollisionRender |
(Ball*, float, float, float, float) |
Custom collision visualization |
| 0x401660 | Ball_SetName |
(Ball*, char*) |
Set display name |
| 0x4016F0 | Ball_ApplyForceV2 |
(Ball*, float, float, float) |
Apply impulse vector |
| 0x401920 | Ball_RenderShadow |
(Ball*) |
Manual shadow render |
| 0x401CC0 | Ball_dtor2 |
(Ball*) |
Full destructor |
| 0x401DD0 | Ball_CreateTrailParticles |
(Ball*, int, float, float, float) |
Spawn trail effect |
| 0x402030 | Ball_SetTargetPos |
(Ball*, float, float, float) |
Set target position |
| 0x402200 | Ball_Shrink |
(Ball*) |
Enter odd race shrink state |
| 0x402270 | Ball_Grow |
(Ball*) |
Exit odd race shrink state |
| 0x402400 | Ball_DizzyImmunity |
(Ball*) |
Grant dizzy immunity |
| 0x402650 | Ball_ApplyForceWithMultipliers |
(Ball*, float, float, float, float, float) |
Force with scale factors |
| 0x4027F0 | Ball_dtor |
(Ball*) |
Light destructor |
| 0x402810 | Ball_TestPlaneIntersection |
(Ball*, float, float, float, float) |
Test plane collision |
| 0x402860 | Ball_InitRenderState |
(Ball*) |
Reset render contexts |
| 0x4029C0 | Ball_SetSpeed |
(Ball*, float) |
⚠️ DEAD CODE — does nothing lasting |
| 0x402A20 | Ball_SetVec3AtOffset |
(Ball*, int offset, float, float, float) |
Generic Vec3 write |
| 0x402DE0 | Ball_Render |
(Ball*) |
Full render (shadow + sprite + material) |
| 0x4030B0 | Ball_ResetCollisionMesh |
(Ball*) |
Reset physics body |
| 0x403100 | Ball_SetTiltedGravity |
(Ball*, float, float, float) |
Set custom gravity direction |
| 0x4039E0 | Ball_ctor2 |
(void* mem, Scene*) |
Constructor |
| 0x405E00 | Ball_Update |
(Ball*) |
Main physics tick |
| 0x45D8F0 | Ball_RenderWithMaterial |
(Ball*, Material*) |
Render with custom material |
| 0x46EC30 | Ball_GetInputForce |
(InputHandler*, float[2]) |
Read input as 2D force vector |
5. Modding Recipes
Recipe A: Instant Teleport
// Write destination, set flag — Ball_Update phase 18 handles the rest
float* ball = (float*)0xDEADBEEF; // your ball pointer
*(bool*)(ball + 0xC3C/4) = true; // teleport_active
ball[0xC40/4] = targetX; // teleport_x
ball[0xC44/4] = targetY; // teleport_y
ball[0xC48/4] = targetZ; // teleport_z
Recipe B: Super Speed
// Phase 20 hook: override velocity after integration, before clamping
float* vel = &ball[0x170/4];
float speed = sqrtf(vel[0]*vel[0] + vel[1]*vel[1] + vel[2]*vel[2]);
if (speed > 0) {
float scale = 3.0f; // 3× speed
vel[0] *= scale; vel[1] *= scale; vel[2] *= scale;
}
Recipe C: Disable Gravity
// Phase 17 hook: zero gravity vector
float* gravity = &ball[0x1A8/4];
gravity[0] = 0; gravity[1] = 0; gravity[2] = 0;
// Also zero gravity_scale
ball[0x278/4] = 0.0f;
Recipe D: Low Friction (Ice Mode)
// Phase 20 hook: reduce velocity decay
// Ball_Update applies friction 3×; skip or reduce it
// Hook at the spin-friction loop and multiply retention
Recipe E: Giant Ball
// Modify radius — affects collision and render scale
ball[0x284/4] = 100.0f; // 27.0f → 100.0f
Recipe F: Position Override (Noclip)
// Phase 19 hook: directly overwrite position
ball[0x164/4] = desiredX;
ball[0x168/4] = desiredY;
ball[0x16C/4] = desiredZ;
// Zero velocity to prevent fight-back
ball[0x170/4] = ball[0x174/4] = ball[0x178/4] = 0;
Recipe G: Input Force Scaling
// Ball_GetInputForce reads +0x50C-0x518 from InputHandler
// Scale the output vector: output[0] *= 2.0f; output[1] *= 2.0f;
Recipe H: Disable Fall State
// Keep unused_init_flag = false (NOTE: this flag is DEAD code, never read by any function)
*(bool*)(ball + 0x281) = false; // dead flag (never read) // dead flag (never read)
6. Cross-Reference Map
| Related Document | What It Covers |
|---|---|
| COLLISIONMESH_OBJECT_MODDING.md | The Ball+0x1A4 physics body (velocity, mass, collision tree) |
| SCENE_OBJECT_MODDING.md | The Ball+0x14 Scene (level geometry, camera, lighting) |
| APP_OBJECT.md | The Ball+0x10 App (global state, window, input handler) |
| INPUT_SYSTEM.md | How Ball_GetInputForce reads DIK codes and joystick axes |
7. Verification Notes
- 99 unique offsets extracted from
Ball_Updatedecompilation (43,655 bytes) - Field names verified against
Ball_ctor2writes (7,400 bytes) - Function names verified against
list_functionsREST endpoint (3,801 total functions, 3,782 documented = 99.5%) - Render offsets verified against
Ball_Render(3,900 bytes) - Input offsets verified against
Ball_GetInputForce(2,000 bytes) - All decompilations performed live via GhidraMCP headless server (v5.2.0, port 8089)
Document: docs/BALL_UPDATE_OBJECT_MODDING.md
Generated: 2025-06-05
Verified against: Hamsterball.exe, GhidraMCP v5.2.0
🔗 Related Documents
Ball Physics Decompilation
Hamsterball - Ball Physics Decompilation
Overview
The ball (hamsterball) is the player-controlled object. It derives from GameObject and has a rich vtable with physics, collision, and rendering methods.
Ball Vtable (0x4CF3A0)
| Offset | Address | Name | Description |
|---|---|---|---|
| +0x00 | 0x4027F0 | Ball_dtor | Destructor (calls Ball_Cleanup) |
| +0x04 | 0x405100 | (thunk) | Update method (jumps to Ball_Update at 0x405190) |
| +0x08 | 0x402DE0 | Ball_CollisionCheck | Per-frame collision check against mesh |
| +0x0C | 0x402A70 | (not defined) | Possibly render setup |
| +0x10 | 0x408390 | (not defined) | Unknown method |
| +0x14 | 0x401590 | (not defined) | Unknown method |
| +0x18 | 0x402650 | Ball_ApplyForce | Apply directional force (x,y,z,magnitude) |
| +0x1C | 0x402C10 | (not defined) | Unknown method |
| +0x20 | 0x409480 | (not defined) | Unknown method |
Base class vtable: GameObject at 0x4CF314
Ball Object Layout (0xC98 bytes)
| Offset | Type | Description |
|---|---|---|
| 0x00 | void** | Vtable pointer |
| 0x04 | int | level reference (param_1 in ctor) |
| 0x08 | int | Parent object reference |
| 0x0C | float[6] | Collision planes (4 component each: aX + bY + cZ + d) |
| 0x42 | Timer | Per-object timer |
| 0x59 | float | Ball X position |
| 0x5A | float | Ball Y position |
| 0x5B | float | Ball Z position |
| 0x62 | float | Ball size (default 0x40400000 = 3.0f) |
| 0x69 | void* | Sound object |
| 0x82 | unknown | Sound data |
| 0x96 | int | Sound handle |
| 0x99 | unknown | More sound data |
| 0xAA | unknown | String data |
| 0xC8 | float | Position component? |
| 0x154 | IDirect3DDevice8* | D3D device pointer (set in render) |
| 0x164 | float | X position (authoritative) |
| 0x168 | float | Y position (authoritative) |
| 0x16C | float | Z position (authoritative) |
| 0x170 | float | X velocity (zeroed on collision) |
| 0x174 | float | Y velocity (zeroed on collision) |
| 0x178 | float | Z velocity (zeroed on collision) |
| 0x17C | float | Acceleration X (zeroed on collision) |
| 0x180 | float | Acceleration Y (zeroed on collision) |
| 0x184 | float | Acceleration Z (zeroed on collision) |
| 0x1A4 | int | Level state reference |
| 0x1C | void** | Physics vtable (for collision mesh interaction) |
| 0x284 | float | Ball radius / height offset |
| 0x2CC | byte | Force disable flag |
| 0x2DC | float | X position (secondary) |
| 0x2E0 | float | Y position (secondary) |
| 0x2E4 | float | Z position (secondary) |
| 0x2F0 | int | Frame counter (affects force scaling) |
| 0x2F8 | byte | Active flag |
| 0x2F9 | byte | Collision occurred flag |
| 0x2FC | int | Physics parameter |
| 0x300 | int | Value 0x96 (150) - possibly mass or timer constant |
| 0x310 | byte | State flag (1 = active) |
| 0x314 | float | Home X position |
| 0x318 | float | Home Y position |
| 0x31C | byte | Home valid flag |
| 0x31D | byte | Home valid flag 2 |
| 0x324 | byte | Special state (shrunk/slow -> affects force multiplier) |
| 0x440 | void* | Allocated physics data |
| 0x6F8 | int | D3D device sub-count |
| 0x6FC | int | Initialized flag for rendering |
| 0x700 | byte | Render mode flag (affects lighting: on/off) |
| 0x708 | int | Render parameter |
| 0x70D | byte | Render state initialized flag |
| 0x734 | byte | Sub-render mode |
| 0x748 | int | Collision mesh pointer (0 = use default) |
| 0x76A | byte | Flag (zeroed on collision) |
| 0x7C8 | int | Render call counter |
| 0x808 | int | Freeze counter (skip force if > 0) |
| 0x810 | AthenaList | Force application list |
| 0x894 | AthenaList | Object tracking list |
| 0xA1 | float | Ball radius (from SIZE parameter) |
| 0x9F | int | Counter (0 = start) |
| 0xC28 | void* | Allocated buffer |
| 0xC2C | char[] | Collision target name buffer |
| 0xC5C | int | Alternate physics state flag (-> different force multiplier) |
| 0xC74 | int | Collision counter (incremented on collision) |
| 0xC80 | byte | Flag (0) |
| 0xC84 | Quaternion | Object rotation (identity = 0,0,0,1) |
| 0xC88 | float[4] | Rotation matrix/quaternion data |
Key Physics Constants (Data Section)
| Address | Value | Description |
|---|---|---|
| 0x4CF368 | float | Collision radius threshold |
| 0x4CF374 | float | Force multiplier when C5C flag set |
| 0x4CF378 | float | Force multiplier when 324 flag set (shrunk) |
| 0x4CF380 | float | Force scaling for frame 0 (first frame) |
| 0x4CF3E8 | float | Force direction multiplier |
| 0x4CF48C | float | Gravity/height offset constant |
Ball_Update (0x405190) - Main Physics Loop
18,499 chars of decompiled code. Key operations:
- Reset frame state (flags, counters, collision mesh)
- If collision mesh flag set, call Ball_ResetCollisionMesh (0x4030B0)
- Iterate through level object list, checking for nearby objects
- For each object, check distance (sqrt(dx²+dy²+dz²))
- Find closest collision point using Mesh_FindClosestCollision (0x465D90)
- On collision:
- Set ball position to collision point + radius offset
- Zero out velocity and acceleration
- Set collision flag (+0x2F9 = 1)
- Reset physics parameters
Ball_ApplyForce (0x402650) - Force Application
Takes (float x, float y, float z, float magnitude).
- Skipped if collision flag (+0x2F9) or disable flag (+0x2CC) set
- Skipped if freeze counter (+0x808) > 0
- Frame 0 applies different force scaling (_DAT_004CF380)
- Shrunk state (+0x324) applies multiplier _DAT_004CF378
- Alternate state (+0xC5C) applies multiplier _DAT_004CF374
- Force direction modified by _DAT_004CF3E8
- Updates velocity components at +0xFC, +0x100
Ball_CheckCollisionPlanes (0x402810) - Plane Collision
Tests ball position against 6 collision planes stored at object+0xC.
Each plane has 4 float components (a,b,c,d) for plane equation: ax + by + cz + d = 0.
Returns byte result: 0 = no collision, 1 = collision detected.
Ball_CollisionCheck (0x402DE0) - Frame Collision
Called from vtable+8. Performs:
- Graphics_BeginFrame
- Ball_CheckCollisionPlanes with ball position + radius 200.0
- If collision: increment counter, call collision response vtable methods
- If no collision: decrement counter (clamped to 0)
Ball_Render (0x402860) - D3D8 Rendering
Sets up D3D8 render states:
- SetRenderState(D3DRS_ZENABLE, 1)
- SetRenderState(D3DRS_CULLMODE, 1)
- SetRenderState(D3DRS_SPECULARENABLE, 1)
- SetTexture(0x112, ...)
- SetRenderState(D3DRS_LIGHTING, flag) based on +0x700 byte
- DrawPrimitiveUP with vertex format depending on +0x700
- Calls FUN_00453970 (sub-object render based on +0x734 mode)
Collision System Summary
Two-tier collision:
- Ball_CheckCollisionPlanes (0x402810): Simple plane test for basic boundaries
- Mesh_FindClosestCollision (0x465D90): Full mesh ray-trace for accurate terrain collision
- Uses AthenaList to build collision planes
- Calls FUN_004564C0 for actual intersection math
- Returns closest hit point with 0.01 precision threshold
🔗 Related Documents
Ball Tint Mod
Ball Tint Mod
Tints player 1's ball to any hex color, read from a text file at runtime.
How It Works
The mod creates ball_tint.txt next to bass.dll on first launch. Edit the file with any hex color (e.g. FF6B35 for orange) and the ball recolors within ~60ms — no restart needed.
Mechanism: Writes RGBA floats into the ball's render context material (ball+0x20C diffuse, +0x21C ambient, +0x23C emissive), then sets the Graphics material override (gfx+0x7C0 = ball+0x208) so the game uses our material instead of the mesh's default white material. This tints both the 3D sphere body and the sprite overlays (border, hamster).
Files
bass.dll— the mod (replace game's bass.dll)ball_tint.txt— auto-created config fileball_tint.c— source code
Usage
- Backup your original
bass.dll - Copy
bass.dllto the game folder - Make sure
bass_real.dll(the original renamed) is in the same folder - Launch the game —
ball_tint.txtis created automatically - Edit
ball_tint.txtwith a hex color (e.g.4A90D9for blue) - Save the file — ball recolors instantly
Config Format
FFFFFF
# Ball Tint Color (hex RGB, no alpha)
# Examples: FF6B35 (orange), 4A90D9 (blue), 2ECC71 (green)
# Lines starting with # are ignored
Supported formats: FF6B35, #FF6B35, 0xFF6B35 (case-insensitive).
Build
i686-w64-mingw32-gcc -shared -o bass.dll ball_tint.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Tested
- ✅ Crash test: 35s on Wine/Xvfb, process alive
- ⚠️ Visual testing on real Windows required (Wine/llvmpipe renders black)
🔗 Related Documents
Ball_Update Decomposition (0x4
Ball_Update Decomposition (0x405190)
Function Signature
void __fastcall Ball_Update(int param_1); // param_1 = Ball*
Overview
Ball_Update is the main per-frame physics update for the ball. It handles:
- Reset per-frame state
- Find the closest collision surface (floor/wall) based on gravity plane
- Snap ball to surface and zero velocity when surface found
- If no surface found, ball falls freely
New Ball Structure Offsets Discovered
| Offset | Type | Field | Description |
|---|---|---|---|
| +0x150 | uint32 | ??? | Reset to 0 each frame |
| +0x18 | int | player_index | 0 or 1 (player), -1 = AI/none |
| +0x1A4 | int | level_ptr | Pointer to Level object |
| +0x2DC | float | ??? | Used as X/Z position for collision |
| +0x2E0 | float | ??? | Used as Y/height for collision |
| +0x2E4 | float | ??? | Used as Z position for collision |
| +0x2E8 | char | flag1 | Reset to 0 each frame |
| +0x2E9 | char | impact_shatter | ⚠ NOT "reset to 0 each frame"! Sticky flag, only cleared by Ball_ctor2 (full respawn). The param_1 + 0x2e9 = 0 in decompiled code uses int* arithmetic (= byte 0xBA4), NOT byte 0x2E9. |
| +0x2EC | uint32 | ??? | Reset to 0 each frame |
| +0x2F0 | uint32 | force_count | Number of forces this frame |
| +0x2F8 | char | update_in_progress | Set to 1 during update |
| +0x2F9 | char | frozen | Ball is stuck/frozen on surface |
| +0x2FC | uint32 | freeze_timer | Countdown when frozen |
| +0x29C | float | scale | Reset to 1.0 (0x3F800000) |
| +0x300 | uint32 | ??? | Set to 150 (0x96) on surface snap |
| +0x310 | char | ??? | Set to 1 each frame |
| +0x314 | float | ang_vel_y | Set to 0 on snap |
| +0x318 | float | ang_vel_x | Set to 0 on snap |
| +0x31C | char | ??? | Set to 0 on snap |
| +0x31D | char | ??? | Set to 0 on snap |
| +0x324 | char | in_tube | If true, skip entire update |
| +0x76A | char | ??? | Set to 0 on snap |
| +0x768 | char | ??? | Set to 1 each frame |
| +0x810 | AthenaList | path_list | Ball path tracking |
| +0xC28 | void* | free_ptr | Freed and nulled each frame |
| +0xC2C | char[] | section_filter | Current collision section name |
| +0xC74 | uint32 | collision_count | Reset to 0 each frame |
| +0xC80 | char | ??? | Has special collision flag |
| +0xC88 | float[4] | special_col | Special collision data |
Collision Surface Finding Algorithm
The function has THREE code paths based on gravity_plane (this+0x748):
Gravity Plane 0 (XY - Top-down / standard)
- Iterates through collision surface list at
level+0x1518 - For each collision entry (AthenaList iterator):
- Check section filter: if
this+0xC2Cis set, only surfaces matching(<filter>prefix - Check [Z] and [X] tags: skip surfaces tagged
[Z]or[X] - Compute distance from ball to surface using 3D distance (SQRT)
- Ball position is compared against
(this+0x2E0 - radius - _DAT_004CF48C)for Y threshold - Keep the CLOSEST surface (minimum distance)
- Check section filter: if
Gravity Plane 1 (Y-tilted)
- Similar iteration but checks for
[X]tag (NOT[Z]) - Distance computed with opponent ball comparison for 2-player mode
- Snaps ball differently:
pos.x = radius + surface.x, pos.y = surface.y, pos.z = surface.z
Gravity Plane 2 (XZ)
- Similar iteration but checks for
[Z]tag (NOT[X]) - Snaps ball:
pos.x = surface.x, pos.y = surface.y, pos.z = surface.z - radius
Surface Snap (when closest surface found)
When a valid surface is found (local_84 != NULL):
// Gravity 0: snap Y to surface + radius
ball->y = surface[2] + ball->radius;
// Gravity 1: snap X to radius + surface
ball->x = ball->radius + surface[1];
ball->y = surface[2];
ball->z = surface[3];
// Gravity 2: snap Z to surface - radius
ball->z = surface[3] - ball->radius;
// ALL gravity planes: zero velocity + angular velocity
ball->vx = ball->vy = ball->vz = 0;
ball->ang_vel_x = ball->ang_vel_y = 0;
ball->frozen = 1;
ball->freeze_timer = 150;
Ball-to-Ball Collision (2-Player Mode)
When level+0x234 is set (2-player mode) and ball->player_index != -1:
- Gets the OTHER ball:
level + 0x5DC + (1 - player_index) * 0xA0 - Compares ball position to other ball position
- If distance < ball->radius, skip this collision surface
- This prevents one ball from detecting surfaces near the other ball
Mesh_FindClosestCollision
Called when gravity_plane == 0 and level has active collision mesh:
Mesh_FindClosestCollision(level+0x8B0, &result);
// result is offset from surface position
// Checks if distance > _DAT_004CF484 (some threshold)
Key Global Constants
| Address | Value | Usage |
|---|---|---|
| 0x4CF48C | 2.0 | Y offset threshold (radius + epsilon) |
| 0x4CF484 | 40.0 | Collision mesh distance threshold |
| 0x4CF368 | 0.0 | Epsilon for float comparison |
| 0x4CF380 | 0.25 | Force multiplier when force_count > 0 |
| 0x4CF378 | 0.0 | Force multiplier when in_tube (no force!) |
| 0x4CF374 | 0.2 | Force multiplier when on_ice |
| 0x4CF36C | 0.75 | Force multiplier when is_dizzy |
| 0x4CF3E8 | 6.0 | Ice friction factor |
Level Structure Offsets (from Ball_Update)
| Offset | Type | Field | Description |
|---|---|---|---|
| +0x234 | char | is_2player | Two player mode |
| +0x237 | char | ??? | Active flag for collision |
| +0x878 | int | ??? | Sub-object reference |
| +0x8B0 | void* | collision_mesh | Mesh_FindClosestCollision target |
| +0x1518 | AthenaList | collision_list | List of collision surfaces |
| +0x151C | int | collision_count | Number of collision entries |
| +0x1520 | int* | collision_iter | Per-thread iterator indices |
| +0x1924 | void** | collision_array | Pointers to collision data |
| +0x29D4 | AthenaList | ball_list | List of other ball objects |
| +0x29D8 | int | ball_count | Number of ball entries |
| +0x2DE0 | void** | ball_array | Pointers to ball data |
| +0x5DC | Ball[2] | balls | Two player ball slots (0xA0 each) |
Collision Entry Structure
Each collision entry in the AthenaList has:
[0] char* name // Surface name (e.g. "PLATFORM(X)", "Walls[Y]")
[1] float x // Surface position X (or Y for gravity 1)
[2] float y // Surface position Y (or Z for gravity 1)
[3] float z // Surface position Z (or height)
Tags in Collision Names
[X]— Surface only applies to gravity plane 1 (Y-tilted)[Z]— Surface only applies to gravity plane 0 (standard)(section)— Section filter prefix for zone-based collision
Decompiled from Ghidra output, 477 lines of decompiled C
Date: April 12, 2026
🔗 Related Documents
Batch Decompilation
Batch Decompilation: 10 New Functions (Session 2928)
Date: 2026-06-20
Tool: GhidraMCP v5.12.0-headless, Hamsterball.exe
Selection criteria: Named, undocumented functions spanning diverse game systems (arena hazards, physics, camera, UI, input, rendering).
1. Catapult_Update @ 0x0043E600
Category: Arena Hazard Physics (Catapult + Rotator objects — shared update)
Called via: vtable DATA ref at 0x004D5AFC
Signature: __fastcall Catapult_Update(int *this)
Summary
Updates the Catapult/Rotator arena hazard — a rotating platform that carries balls and launches them. The catapult has a rotation timer at this+0x43C that decreases by this+0x43D (rotation speed) each frame. It applies the rotation matrix to all attached balls (via AthenaList at this+0x43E), transforming both their positions (ball+0x164/0x168/0x16C = X/Y/Z) and their velocity vectors (CollisionMesh+0xCA4/CA8/CAC).
This function serves double duty:
- Catapult launch system — triggered by
E:CATAPULTBOTTOM, launches ball viaCatapult_Launch(0x434290) - Rotator/gear system — triggered by
N:ONROTATOR/N:SPINNY/N:SWIRL, attaches ball viaRotator_AddBall(0x43B6F0) orCatapult_AddObjectConditional(0x43E9C0)
In both cases, the 8-byte ball tracking entry [ball_ptr, tick_counter] is decremented each frame. The tick counter starts at 10 and resets to 10 on every frame of continued collision contact (grace period, not carry limit). When it reaches 0 after the ball leaves the surface, the entry is freed.
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x436..0x438 | float[3] | Catapult pivot position (X,Y,Z) |
| this+0x439 | float | Z-axis rotation amount |
| this+0x43A | float | X-axis rotation amount |
| this+0x43B | float | Y-axis rotation amount |
| this+0x43C | float | Current rotation angle |
| this+0x43D | float | Rotation speed (delta per frame) |
| this+0x43E | AthenaList | Attached balls list header |
| this+0x43F | int | Attached balls count |
| this+0x541 | void** | Ball pointer array |
| ball+0x164/168/16C | float[3] | Ball position (X,Y,Z) |
| ball+0x1A4 | int* | CollisionMesh pointer |
| CollisionMesh+0xCA4/CA8/CAC | float[3] | Ball velocity vector |
Mechanism
- Decrements rotation angle:
this->rotation -= this->rotation_speed - Builds a rotation matrix from X/Y/Z scale components using
Gfx_ScaleX/Y/ZandTimer_Init - For each attached ball: computes delta from catapult pivot, applies rotation matrix, updates ball position and velocity
- Frees expired ball attachments (reference count drops to 0)
- Calls vtable methods
+0x58and+0x54(likely render and post-update hooks)
2. BounceBall_Update @ 0x00440840
Category: Arena Hazard Physics (BounceBall spawner)
Called via: vtable DATA ref at 0x004D5734
Signature: __fastcall BounceBall_Update(int *this)
Summary
A bounce-pad hazard that launches balls into the air on a timer. It has two states:
- State 0 (
this+0x43A == 0): Countdown mode — multipliesthis+0x439(timer) by 1.5 each frame. When timer exceeds 120.0, it spawns aFollowBallentity (a ball that follows a predefined path), resets the timer to 120.0, and flips to state 1. - State 1 (
this+0x43A != 0): Cooldown mode — multiplies timer by 0.95 each frame. When timer drops below 1.0, resets to 0 and returns to state 0.
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x434 | int* | Scene pointer |
| this+0x435..0x437 | float[3] | Bounce pad position |
| this+0x438 | float | Scale X (render) |
| this+0x439 | float | Countdown timer |
| this+0x43A | int | State flag (0=counting up, 1=counting down) |
| this+0x43B | byte | Dirty flag (position changed) |
Spawn Mechanism
When the timer exceeds 120.0:
- Calls
operator_new(0xC68)— allocates a Ball struct (3144 bytes) - Calls
FollowBall_Ctor(ptr, scene, Level_FindObjectByName(scene, "BallPath")) - Looks up "FOLLOWBALLSPOT" in the level's AthenaHashTable → gets spawn position
- Sets ball position from hash table lookup
- Sets ball velocity:
(-3.0, 10.0, 0.0)(upward launch) - Sets
ball+0x80C = 0x0F(flag/mode = 15) - Appends ball to scene's ball list at
scene+0x29D4 - Plays 3D sound at spawn position
Constants
| Address | Value | Description |
|---|---|---|
| _DAT_004cf458 | 1.5 (f64) | Countdown multiplier (state 0) |
| _DAT_004d5dbc | 120.0 (f32) | Spawn threshold |
| _DAT_004d5d98 | 0.95 (f64) | Cooldown multiplier (state 1) |
| _DAT_004cf310 | 1.0 (f32) | Cooldown minimum |
3. Lifter_Update @ 0x0043B330
Category: Arena Hazard Physics (Lifter platform)
Called via: vtable DATA ref at 0x004D5544
Signature: __fastcall Lifter_Update(int *this)
Summary
An oscillating elevator platform that moves up and down on the Z-axis, carrying attached balls. The lifter bounces between position bounds: when it exceeds +2.0, it reverses direction; when it drops below -2.0, it reverses again. It applies its vertical displacement to all balls on the platform.
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x436..0x438 | float[3] | Lifter pivot position |
| this+0x439 | float | Accumulated vertical offset |
| this+0x43A | float | Current oscillation position |
| this+0x43B | float | Direction (-0.5 or +1.0) |
| this+0x43C | AthenaList | Attached balls list |
| this+0x43D | int | Attached balls count |
| this+0x53F | void** | Ball pointer array |
Oscillation Logic
position += direction * 0.004 // _DAT_004d5c88 = 0.004
if position > 2.0: direction = -0.5 // _DAT_004cf48c = 2.0
if position < -2.0: direction = +1.0 // _DAT_004d5c84 = -2.0
Ball Transform
Same matrix transform pattern as Catapult_Update — applies oscillation displacement to each attached ball's position and velocity through Gfx_ScaleX, Matrix_TransformVec3, and Timer_Init/Cleanup.
Constants
| Address | Value | Description |
|---|---|---|
| _DAT_004d5c88 | 0.004 (f32) | Oscillation speed |
| _DAT_004cf48c | 2.0 (f32) | Upper position bound |
| _DAT_004d5c84 | -2.0 (f32) | Lower position bound |
4. ArenaSceneObj_Tick @ 0x0042B660
Category: arena scoring system (ArenaBoard timer)
Called via: vtable DATA ref at 0x004D3A5C
Signature: __fastcall ArenaSceneObj_Tick(int param_1)
Summary
Per-frame tick for ArenaBoard (arena mode) decorative timers. Manages two oscillating timer fields used for visual effects on the arena board (likely animated score displays or arena hazard timing):
- Timer A (
this+0x880): Increments by 0.01 each frame. When it exceeds 0.75, resets to 0.75 (clamps at max). - Timer B (
this+0x884): Decrements by 20.0 each frame. When it drops below 0.0, resets to 0.0 (clamps at min). - Calls
ToggleTimer_Tickon two sub-timer fields atthis+0x888andthis+0x89C.
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x880 | float | Timer A (ramps up to 0.75) |
| this+0x884 | float | Timer B (ramps down to 0.0) |
| this+0x888 | Timer | Sub-timer 1 |
| this+0x89C | Timer | Sub-timer 2 |
Constants
| Address | Value | Description |
|---|---|---|
| _DAT_004cf524 | 0.01 (f32) | Timer A increment |
| _DAT_004cf438 | 0.75 (f64) | Timer A max |
| _DAT_004cf370 | 20.0 (f32) | Timer B decrement |
| _DAT_004cf368 | 0.0 (f32) | Timer B min |
5. Scene_UpdateArenaPhysics @ 0x00440390
Category: Arena Core Loop (Wave physics)
Called via: vtable DATA ref at 0x004D5484
Signature: __fastcall Scene_UpdateArenaPhysics(int *this)
Summary
The main per-frame physics update for arena levels. Runs every 3rd frame (frame counter at this+0x43E, triggers when counter > 2, then resets to 0). This implements the arena's wave/surface physics:
- Frame gate:
this->frame_counter++; if (counter <= 2) return;— runs at 20fps on a 60fps game - Wave computation: For each of
this->num_waves(atthis+0x43B) wave segments:- Computes wave phase:
(frame * 360.0) * (i / num_waves) + base_offset - Applies
Wave_Sin()to get displacement amplitude ×this+0x43D(wave height) - Updates vertex Z-offsets in mesh data arrays (two separate lists:
piVar5+0x102andpiVar5+0x342E)
- Computes wave phase:
- Ball physics: Iterates arena balls (list at
this+0x6A99):- For each ball, finds closest collision mesh point via
Mesh_FindClosestCollision - If ball Y-position is within
this+0x43D(wave height) of the surface, snaps ball Y to surface + ball radius (ball+0x284) - Ball radius stored at
CollisionMesh+0xC98/0xC9C/0xCA0
- For each ball, finds closest collision mesh point via
- Vertex buffer upload: Copies updated vertex data from
this+0x43Fto render buffer, calls vtable methods+0x38and+0x3C(likelyUnlockVertexBufferandDrawPrimitive) - Wave sound: Every 360th frame (
frame % 360 == 0), plays a 3D wave sound at the arena center
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x434 | int* | App/scene context pointer |
| this+0x435 | void* | Collision mesh |
| this+0x43B | int | Number of wave segments |
| this+0x43C | int | Frame counter (×360 for wave phase) |
| this+0x43D | float | Wave height amplitude |
| this+0x43E | int | Frame gate counter (mod 3) |
| this+0x43F | void* | Vertex buffer source |
| this+0x440 | int | Vertex count |
| this+0x6A99 | AthenaList | Arena ball list |
| this+0x6A9A | int | Arena ball count |
| this+0x6B9C | void** | Arena ball pointer array |
| this+0x120 | int* | Graphics/device context |
| ball+0x164/168/16C | float[3] | Ball position |
| ball+0x284 | float | Ball radius |
| ball+0x1A4 | int* | CollisionMesh |
Constants
| Address | Value | Description |
|---|---|---|
| _DAT_004cf454 | 100.0 (f32) | Ball Y surface offset |
| _DAT_004cfecc | 25.0 (f32) | Wave displacement offset |
| _DAT_004d0418 | 180.0 (f32) | Wave frequency (degrees) |
| _DAT_004d03a0 | 90.0 (f32) | Wave phase offset (degrees) |
6. FollowBall_Update @ 0x0043ECC0
Category: Entity AI (FollowBall path follower)
Called via: vtable DATA ref at 0x004D4F64
Signature: __fastcall FollowBall_Update(int *this)
Summary
Updates a FollowBall — a ball that follows a predefined path through the arena (spawned by BounceBall_Update). The FollowBall has two movement modes controlled by this+0x441 (a boolean flag):
- Mode 0 (
this+0x441 == 0): Normal speed — moves at 0.45 multiplier (or 0.09 ifthis+0x43F > 0) - Mode 1 (
this+0x441 != 0): Fast speed — decays spawn velocity by 0.0, clamps at 50.0 max
The FollowBall also manages a spawning cycle:
- When the cycle timer (
this+0x43E) reaches its threshold, it spawns aRegisterDialogentity (a score popup or registration marker) at a random position near the ball - The spawn position uses
RNG_Rand(0, 10)for X/Z offset, with Z offset of 60.0 - Spawns into the scene's dialog list at
scene+0x3B00
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x434 | int* | Scene pointer |
| this+0x435..0x437 | float[3] | Ball position |
| this+0x438..0x43A | float[3] | Ball position (mirror/offset) |
| this+0x43B | float | Animation timer (increments by 8.0) |
| this+0x43C | float | Current speed |
| this+0x43D | float | Target speed |
| this+0x43E | float | Spawn cycle timer |
| this+0x43F | float | Cycle threshold |
| this+0x440 | float | Cycle reset offset |
| this+0x441 | byte | Mode flag (0=normal, 1=fast) |
| this+0x442 | float | Spawn velocity decay |
Constants
| Address | Value | Description |
|---|---|---|
| _DAT_004cf380 | 0.25 (f32) | Base position offset |
| _DAT_004cf4dc | 8.0 (f32) | Animation timer increment |
| _DAT_004cf4d0 | 0.0 (f32) | Spawn decay (mode 1) |
| _DAT_004cf3ec | 50.0 (f32) | Max spawn velocity |
| _DAT_004cf3c8 | 0.0 (f32) | Cycle threshold |
| _DAT_004cf3e0 | 0.0 (f32) | Lower cycle bound |
| _DAT_004d5d78 | 1.12 (f64) | Position base |
| _DAT_004d5d68 | 0.45 (f64) | Normal speed multiplier |
| _DAT_004d5d70 | 0.09 (f64) | Fast speed multiplier |
| _DAT_004d5d60 | 0.9 (f64) | Spawn decay (mode 0) |
| _DAT_004d5d58 | 1.75 (f64) | Cycle max |
| _DAT_004d5d50 | 0.0065 (f64) | Spawn velocity |
| _DAT_004d5d48 | 0.005 (f64) | Spawn velocity alt |
| _DAT_004d0930 | 60.0 (f32) | Z offset |
| _DAT_004d039c | 15.0 (f32) | Scale modifier |
7. Scene_HandleRaceEnd_ClampZoom @ 0x0041F7E0
Category: Race Camera (Finish-line zoom)
Called via: vtable DATA ref at 0x004D0EC4
Signature: __fastcall Scene_HandleRaceEnd_ClampZoom(int this)
Summary
Called when a race ends. First calls Scene_HandleRaceEnd(this) to handle the actual race-finish logic, then clamps 8 zoom-related float values at this+0x644C through this+0x6468 (every 4 bytes = 8 floats). Each value has 0.05 subtracted and is clamped to a minimum of 0.0.
These 8 floats likely represent camera zoom parameters (field-of-view multipliers or distance values for up to 4 players in split-screen). The zoom-out animation at race end reduces each one by 0.05 per frame until they reach 0.0.
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x644C | float | Zoom param 1 |
| this+0x6450 | float | Zoom param 2 |
| this+0x6454 | float | Zoom param 3 |
| this+0x6458 | float | Zoom param 4 |
| this+0x645C | float | Zoom param 5 |
| this+0x6460 | float | Zoom param 6 |
| this+0x6464 | float | Zoom param 7 |
| this+0x6468 | float | Zoom param 8 |
Constants
| Address | Value | Description |
|---|---|---|
| _DAT_004cf428 | 0.05 (f64) | Zoom decrement per frame |
| _DAT_004cf6a8 | 0.0 (f32) | Minimum zoom clamp |
8. App_UpdateCullMode @ 0x00429040
Category: Graphics/Rendering (Cull mode toggle)
Called via: vtable DATA ref at 0x004D26D8
Signature: __fastcall App_UpdateCullMode(int this)
Summary
A tiny function that syncs the D3D cull mode (backface culling) with the current graphics settings. Reads a cull-mode byte from App+0x236 (a settings field — likely the "rendering quality" or "wireframe" toggle) and writes it to the graphics device's cull-mode field at device+0x7D2, then calls Gfx_SetCullMode(device) to apply it.
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| App+0x174 | int* | Graphics device pointer |
| App+0x236 | byte | Desired cull mode (from settings) |
| device+0x7D2 | byte | Current cull mode field |
Mechanism
device = App->graphics_device;
device->cull_mode = App->cull_setting;
Gfx_SetCullMode(device);
9. CreditsScreen_Render @ 0x00425AC0
Category: UI/Screens (Credits display)
Called via: vtable DATA ref at 0x004D2548
Signature: __thiscall CreditsScreen_Render(void *this, void *param_1)
Summary
Renders the end-game credits screen. Draws a scrolling list of credit text entries with special formatting markers:
'-'prefix (0x2D): Renders in a smaller, dimmer font (scale 0.5/0.5/1.0, color tint 0.75 alpha). Used for sub-credits (secondary roles).'*'prefix (0x2A): Renders in a different color/style (scale 1.0/1.0/0.5, color tint 0.5 alpha). Used for section headers. If the entry matchesthis+0x864(currently selected/highlighted entry), applies additional emphasis (scale 0.5/1.0/0.5).- No prefix: Renders at default scale (1.0/1.0/1.0) with full color.
Each entry is drawn with UI_DrawTextCenteredAbsolute at X=400 (screen center), Y computed from __ftol2 (entry index × line height). After rendering all entries, calls UIList_Render for the standard list scrollbar/border.
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x44C | AthenaList | Credit entries list |
| this+0x450 | int | Entry count |
| this+0x858 | void** | Entry pointer array |
| this+0x864 | int | Highlighted entry index |
| this+0xCDc | int* | UI/font context |
| this+0xCE0 | float | Scroll animation value |
Color/Scale Constants
| Hex Value | Float | Meaning |
|---|---|---|
| 0x3F800000 | 1.0 | Full scale/alpha |
| 0x3F400000 | 0.75 | Reduced scale/alpha |
| 0x425BA4 | — | String pointer (default color) |
10. KeyRemapMenu_WaitForKey @ 0x00443430
Category: input system (Key rebinding)
Called via: vtable DATA ref at 0x004D5F54
Signature: __fastcall KeyRemapMenu_WaitForKey(int *this)
Summary
Handles the "Press a key..." state in the key rebinding options menu. Has three code paths:
- Not waiting (
this+0x338 == 0): CallsUIList_ScrollUpdateto handle normal menu scrolling. Returns immediately. - Debounce mode (
this+0x33A != 0): Clears the debounce flag, then scans the DirectInput keyboard state buffer (256 bytes) for any key with the high bit set (& 0x80). If any key is still pressed, re-sets the debounce flag and returns. This ensures the user releases the old key before pressing a new one. - Capture mode (
this+0x338 != 0, this+0x33A == 0): Scans the 256-byte DirectInput keyboard state buffer. When it finds a key with high bit set (& 0x80), it:- Stores the DIK (DirectInput Key) scan code at
*this+0x339(the key binding slot) - Clears the waiting flag (
this+0x338 = 0) - Calls
KeyRemapMenu_UpdateKeyLabelsto refresh the UI - Sets a timer at
scene+0x560to 0x32 (50) — likely a UI cooldown
- Stores the DIK (DirectInput Key) scan code at
Key Struct Offsets
| Offset | Type | Description |
|---|---|---|
| this+0x21E | int* | App/scene context |
| this+0x338 | byte | Waiting-for-key flag (1=capturing) |
| this+0x339 | int* | Key binding storage slot pointer |
| this+0x33A | byte | Debounce flag (1=waiting for key release) |
| App+0x180 | int* | Input device context |
| InputDevice+0x434 | int* | DirectInput keyboard state buffer |
| InputDevice+0x434+0xC | byte[256] | DIK key state array (high bit = pressed) |
| scene+0x560 | int | UI cooldown timer |
DirectInput Scanning
The 256-byte keyboard state buffer is the standard DirectInput IDirectInputDevice8::GetDeviceData format. Each byte represents one DIK code; bit 7 (0x80) is the "pressed" flag. The function scans all 256 keys linearly.
Summary
| # | Function | Address | System | Lines |
|---|---|---|---|---|
| 1 | Catapult_Update | 0x0043E600 | Arena Hazard | 141 |
| 2 | BounceBall_Update | 0x00440840 | Arena Hazard (spawner) | 99 |
| 3 | Lifter_Update | 0x0043B330 | Arena Hazard (elevator) | 149 |
| 4 | ArenaSceneObj_Tick | 0x0042B660 | Arena Scoring | 22 |
| 5 | Scene_UpdateArenaPhysics | 0x00440390 | Arena Wave Physics | 182 |
| 6 | FollowBall_Update | 0x0043ECC0 | Entity AI | 125 |
| 7 | Scene_HandleRaceEnd_ClampZoom | 0x0041F7E0 | Race Camera | 50 |
| 8 | App_UpdateCullMode | 0x00429040 | Graphics | 11 |
| 9 | CreditsScreen_Render | 0x00425AC0 | UI/Credits | 126 |
| 10 | KeyRemapMenu_WaitForKey | 0x00443430 | input system | 38 |
Total: 943 lines of decompiled C documented.
Architecture Notes
All 10 functions are called exclusively via vtable DATA references (virtual dispatch). None are called directly. This confirms the engine's C++-style polymorphism: each game object type (Catapult, BounceBall, Lifter, FollowBall, etc.) has its own vtable with Update, Render, and Tick virtual methods. The Scene's main loop iterates object lists and calls the appropriate vtable slot.
Common Patterns
- Timer_Init/Timer_Cleanup: Structured exception handling (SEH) frame setup/teardown used as scoped profiling/timing wrappers.
- AthenaList_NextIndex: Iterator pattern for the engine's custom linked-list/array hybrid container.
- Gfx_ScaleX/Y/Z: These are NOT just scale operations — they build transformation matrix rows used for rotation/translation of attached objects.
_DAT_globals: Mix of f32 and f64 constants. Ghidra doesn't distinguish — must read 8 bytes and try both interpretations.
🔗 Related Documents
Binary [[21334133727843|meshworld format]]
Binary MESHWORLD Format — Complete Decompilation
Based on Ghidra decompilation of FUN_004629E0 (Level::BinaryLoader)
File Structure (Sequential reads)
=== SECTION 1: Materials (0x7C structure each) ===
[uint32] material_count
For each material:
[uint32] name_length
[name_length bytes] name (allocated, stored at material+0x00)
[uint32] field1 -> material[1] (position X or face_start, float value for objects)
[uint32] field2 -> material[2] (position Y)
[uint32] field3 -> material[3] (position Z)
[uint32] field4 -> material[4] (often 0)
[uint32] field5 -> material[5] (often 0)
[uint32] field6 -> material[6] (often 0x80000000 = -0.0f)
[4 bytes] extended_flag -> material[10] (checked as char, rest padding)
If extended_flag != 0:
[4 × float] ambient_rgba -> material[0x10..0x13]
[4 × float] diffuse_rgba -> material[0x0C..0x0F]
[4 × float] specular_rgba -> material[0x14..0x17]
[4 × float] emission_rgba -> material[0x18..0x1B] *** WAS MISSING ***
[float] shine -> material[0x1C]
[uint32] has_reflective -> stored as bool at material+0x79
[uint32] has_texture (checked with == 1, not != 0)
If has_texture == 1:
[uint32] texture_name_length
[texture_name_length bytes] texture_filename
(loaded via Graphics_LoadTexture)
NOTE: Section 1 items include BOTH materials AND game objects!
In WarmUp (16 items): START2-2, START2-1, CAMERALOOKAT,
PLATFORM(x5, ext=1), SAFESPOT(x5), STANDS, START2-4, START2-3
The 6 u32 "fields" are actually float[3] position + 3 padding values.
=== SECTION 2: Mesh Buffers (0x54 structure each) ===
[uint32] mesh_buffer_count
For each mesh buffer:
[uint32] data_length
[data_length bytes] raw_data (stored at buffer+0x50)
[uint32] face_count
For each face:
[3 × uint32] face_data (12 bytes per face)
FUN_004685e0(buffer) processes vertex data
FUN_00469090(buffer, 0) finalizes buffer
=== SECTION 3: Game Objects (0xD4 structure each) ===
[uint32] object_count
For each object:
[uint32] object_type (0 = straight/ramp, non-zero = special)
If object_type == 0:
[3 × float] position (3 reads via vtable+4)
[3 × float] euler_rotation (3 reads, 2 via vtable+8, skip pattern)
[3 × float] scale/transform
[D3D color/material init]
(All objects added to level+0x478 list)
=== SECTION 4: Bounding Box ===
[float] min_x -> level+0x454
[float] min_y -> level+0x458
[float] min_z -> level+0x45C
[float] max_x -> level+0x468
[float] max_y -> level+0x46C
[float] max_z -> level+0x470
Note: In the Level STRUCT, there's a 12-byte gap between +0x45C and +0x468
(+0x460, +0x464, +0x468). But from the FILE it's 6 sequential reads (24 bytes).
**IMPORTANT**: For many Arena levels, the bbox values in the file are INVALID
(min > max for some axes). They appear to be placeholder/garbage values.
The game likely computes the real bbox from vertex data at runtime.
Our reimpl recomputes bbox from vertex positions if invalid or all-zeros.
Example invalid bbox from Arena-WarmUp: (0.98, 0.46, 1.0, 0.56, 0.56, 0.56)
Example zero bbox from Arena-SpawnPlatform: (0, 0, 0, 0, 0, 0)
=== SECTION 5: Vertex Array ===
[uint32] vertex_count -> level+0x438
(level+0x43C = 0, buffer allocated = vertex_count * 32 bytes at level+0x440)
[vertex_count * 32 bytes] vertex data
Vertex format (32 bytes each):
[3 × float] position (x, y, z) // 12 bytes
[3 × float] normal (nx, ny, nz) // 12 bytes
[2 × float] texcoord (u, v) // 8 bytes
**RENDERING NOTE**: Vertex data is in TRIANGLE-LIST order.
Every 3 consecutive vertices form a single triangle.
Confirmed with SpawnPlatform: V0-V5 = top face (2 tris), V6-V11 = front face,
V12-V17 = right face, V18-V23 = back face, V24-V29 = left face (10 tris total).
Use D3DPT_TRIANGLELIST with DrawPrimitive, prim_count = vertex_count / 3.
=== SECTION 6: Post-load ===
level+0x43C = 0 (vertex_data_ready flag)
level->self_ptr = this (level+0x47C)
level+0x431 = 1 (loaded flag)
Timer_Init for level+0x10D
(**vtable+0x34)(file_handle, this) // callback
__close(file_handle)
FUN_004601A0(this) // finalize load
=== Trailing data (after vertex array in some files) ===
After the vertex data, some files contain:
- Material color overrides (ambient/diffuse/specular RGBA, shine, etc.)
- Texture name strings (e.g. "PinkChecker.bmp\0")
- Face index arrays (pairs of: vertex_index + triangle_group_flag)
Level Structure Offsets (from MeshWorld object)
| Offset | Size | Field | Description |
|---|---|---|---|
| +0x120 | ptr | mesh_data | Pointer to mesh data master struct |
| +0x438 | uint32 | vertex_count | Number of vertices |
| +0x43C | uint32 | zero_flag | Set to 0 after vertex allocation |
| +0x440 | ptr | vertex_array | Vertex data (32 bytes each) |
| +0x454 | float | min_x | Bounding box min X |
| +0x458 | float | min_y | Bounding box min Y |
| +0x45C | float | min_z | Bounding box min Z |
| +0x460 | ??? | ??? | (gap in struct layout) |
| +0x468 | float | max_x | Bounding box max X |
| +0x46C | float | max_y | Bounding box max Y |
| +0x470 | float | max_z | Bounding box max Z |
| +0x478 | list | object_list | Game objects (AthenaList) |
| +0x47C | ptr | self_ptr | Pointer back to this object |
| +0x894 | list | material_list | Materials (AthenaList) |
| +0xCAC | list | mesh_buffer_list | Mesh buffers (AthenaList) |
| +0x10D | ptr | timer | Timer object |
Material Structure (0x7C bytes)
| Offset | Size | Field | Description |
|---|---|---|---|
| +0x00 | ptr | name | Material name string |
| +0x04 | uint32 | field1 | Position X (float) or face_start |
| +0x08 | uint32 | field2 | Position Y (float) or face_count |
| +0x0C | float[4] | diffuse | Diffuse color RGBA |
| +0x10 | float[4] | ambient | Ambient color RGBA |
| +0x14 | float[4] | specular | Specular color RGBA |
| +0x18 | float[4] | emission | Emission color RGBA |
| +0x1C | float | shine | Shininess |
| +0x28 | byte | extended | Extended flag (byte from u32 read) |
| +0x74 | bool | has_reflective | Reflective material flag |
| +0x75 | bool | has_texture | Texture present flag |
| +0x78 | ptr | texture_ptr | Loaded texture pointer |
| +0x79 | bool | is_transparent | Alpha != -0.0 |
Global Constants
| Address | Float Value | Usage |
|---|---|---|
| 0x4CF3C8 | -0.0 (0x80000000) | Transparency comparison value |
Verified File Data
Arena-SpawnPlatform.MESHWORLD (1,476 bytes)
- material_count=0, meshbuf_count=0, obj_count=0
- bbox at offset 12: all zeros (placeholder)
- vertex_count=34 at offset 36
- 34 vertices at offset 40 (34 × 32 = 1088 bytes)
- Real bbox at offset 1128: (-160, 2, -149) to (172, 13, 145)
- Trailing data: face indices + material data
Arena-WarmUp.MESHWORLD (83,254 bytes)
- material_count=16 (includes START, PLATFORM, SAFESPOT, STANDS items)
- 5 PLATFORM items have ext=1 with ambient/diffuse data
- Items 0-2,8-16 have no extended section
- meshbuf_count=0 after materials (at offset 1042)
- obj_count=1 at offset 1046
- Object type=0 at offset 1050, position=(634.2, 286.6, -375.3)
- Bbox+vertices location: TBD (need to parse object section correctly)
- File ends with "PinkChecker.bmp" material data and face indices
🔗 Related Documents
Browser D3D8 Shim
Hamsterball Browser D3D8 Shim — Phase 1: API Verification
Binary
- Path:
originals/installed/extracted/Hamsterball.exe - Size: 1,404,928 bytes
- MD5:
7d25019366b8d7f55906325bd630d7fe - Type: PE32 i386 executable
Import Verification
DirectX Graphics API
- Imports
d3d8.dll— confirmed viaobjdump -p - Only one D3D8 import:
Direct3DCreate8 - All other D3D8 calls go through COM interface vtables (IDirect3D8, IDirect3DDevice8, etc.)
- No
d3d9.dll,ddraw.dll, ordxgi.dllimports
Dead Code Strings
d3d9.dll,d3d8d.dll,D3D9— present as strings but NOT imported- These are dead code in the Athena engine (optional API path compiled out)
Other Graphics Dependencies
GDI32.dll— standard Win32 GDI (window, font, bitmap)USER32.dll— window management, input messagesSHELL32.dll— shell operations
Input
DINPUT8.dll— DirectInput 8 keyboard input
Audio
BASS.dll— BASS audio library (music/sound)DSOUND.dll— DirectSound for sound effects
Networking
WS2_32.dll— Winsock (eSellerate DRM/registration)eSellerateEngine.dll— DRM/licensing
Conclusion
Hamsterball is a fixed-function Direct3D 8 game. It uses:
Direct3DCreate8→ COM interfaces- No shaders (D3D8 fixed-function pipeline)
- DrawPrimitiveUP for rendering
- DirectInput for input
- BASS + DirectSound for audio
No D3D9 path exists at runtime. The shim must implement D3D8 COM interfaces.
🔗 Related Documents
Camera System
Camera System
Overview
Hamsterball uses a 5-mode camera system controlled by Scene_SetCamera (0x419FA0).
The camera follows the ball with spring-damped interpolation, and supports path-based
rails, shake effects, snap transitions, and orbital rotation.
Camera Modes
1. Default Follow (no flags)
Camera target = ball position + offset (Scene+0x434C, Scene+0x4350, Scene+0x4354).
Camera smoothly follows ball with spring damping (hardcoded 0.9 factor).
2. Path Rail Mode (Scene+0x3F1C != 0)
When a path is active, the camera rides along a spline path:
Path_GetPosition(path, &pos, path_t)gets current path position- Direction from path to ball is computed
- If distance < threshold → camera offset = 0 (ball on rail)
- If distance > threshold → offset scales linearly with sine wobble
- Max camera offset capped at 700.0 units
Ball_SetTargetPossets ball's camera target position
Path rail adds a spring-like oscillation using Wave_Sin modulation,
creating a rubber-band effect when the ball deviates from the path.
3. Camera Shake (Ball+0x744 != 0)
When shake flag is set, random offsets of ±50 units are added to camera position:
shake_x = RNG_Rand(-50, 50)
shake_y = RNG_Rand(-50, 50)
shake_z = RNG_Rand(-50, 50)
camera_pos += (shake_x, shake_y, shake_z)
4. Camera Snap (Scene+0x3F2C != 0, countdown frames)
When snap countdown > 0:
- Set ball target pos directly to ball's forward vector (Ball+0x60..0x68)
- Set ball actual cam pos to ball's forward vector
- Decrement countdown each frame
Used for race start countdowns and scene transitions.
5. Orbital Rotation (always active)
Every frame, regardless of mode:
cos_angle = Wave_Cos(Scene+0x29BC) // orbit angle
sin_angle = Wave_Sin(Scene+0x29BC) // orbit angle
orbit_dir = (cos_angle, 0.9, sin_angle) // slightly above horizontal
Camera_SetView(orbit_dir, Scene+0x29C0) // distance
Graphics_Refresh() // commit camera
Scene_SetCamera (0x419FA0)
void Scene_SetCamera(Scene* this, Ball* ball, char use_path) {
// Start with ball's target cam position
Vec3 cam_target = ball->cam_target; // +0x758
Vec3 scene_offset = this->cam_offset; // +0x434C
Vec3 desired = cam_target + scene_offset;
// Mode 2: Path rail (if path active and use_path enabled)
if (this->path_active && use_path) {
Vec3 path_pos = Path_GetPosition(this->path, this->path_t);
Vec3 delta = desired - path_pos;
float dist = Vec3_Length(delta);
if (dist < threshold) {
// On rail - no offset
offset = 0.0f;
} else {
if (dist > 700.0f) dist = 700.0f;
dist -= MIN_DIST; // _DAT_004cf3ec
float wave = Wave_Sin(dist * WAVE_SCALE); // oscillation
offset = dist - dist * DAMPING * wave; // spring
}
Vec3 offset_vec = Vec3_NormalizeAndScale(delta, offset);
desired = path_pos + offset_vec;
Ball_SetTargetPos(ball, desired.x, desired.y, desired.z);
}
// Mode 3: Camera shake
if (ball->shake_flag) { // +0x744
desired.x += RNG_Rand(-50, 50);
desired.y += RNG_Rand(-50, 50);
desired.z += RNG_Rand(-50, 50);
}
// Mode 4: Camera snap (countdown)
if (this->snap_countdown > 0) { // +0x3F2C
this->snap_countdown--;
desired = ball->forward; // +0x60..0x68
ball->cam_actual = desired; // +0x76C..0x774
ball->cam_target = desired; // +0x758..0x760
}
// Mode 5: Orbital rotation (always)
float cos_a = Wave_Cos(this->orbit_angle); // +0x29BC
float sin_a = Wave_Sin(this->orbit_angle);
Vec3 orbit_dir = {cos_a, 0.9f, sin_a};
Camera_SetView(orbit_dir, this->orbit_distance); // +0x29C0
Graphics_Refresh();
}
CameraLookAt (Arena Camera) (0x413280)
Arena initialization sets up a fixed overhead camera:
1. Load "levels\arena-spawnplatform" mesh (MeshWorld_ctor, 0x10D0 bytes)
2. Load "levels\arena-stands" mesh (MeshWorld_ctor, 0x10D0 bytes)
3. Level_InitScene (build scene graph from loaded meshes)
4. Find "CAMERALOOKAT" object in hash table
5. Store target position: Scene+0x43BC..0x43C4 = CAMERALOOKAT pos
6. Store initial position: Scene+0x43AC..0x43B4 = CAMERALOOKAT pos
7. Set camera distance: Scene+0x29BC = 45.0f (0x42340000)
8. Set camera height: Scene+0x29C0 = 800.0f (0x44480000)
9. Set max height: Scene+0x43C8 = 800.0f (0x44480000)
10. Set camera mode: Scene+0x43CC = 1 (orbits)
11. Call vtable[0x54]() to apply camera settings
Level_SelectCameraProfile (0x40ACA0)
Selects camera profile based on level type:
- Different profiles for: Warm Up, Beginner, Intermediate, etc.
- Adjusts orbit distance, height, damping factor
Key Offsets
| Offset | Type | Description |
|---|---|---|
| Ball+0x60..0x68 | Vec3 | Ball forward vector |
| Ball+0x164..0x16C | Vec3 | Ball position |
| Ball+0x758..0x760 | Vec3 | Camera target position |
| Ball+0x76C..0x774 | Vec3 | Camera actual position |
| Ball+0x744 | int | Camera shake flag |
| Scene+0x29BC | float | Camera orbit angle |
| Scene+0x29C0 | float | Camera orbit distance |
| Scene+0x3F1C | int | Path rail active flag |
| Scene+0x3F20 | Path* | Path object pointer |
| Scene+0x3F24 | float | Path parameter (t) |
| Scene+0x3F2C | int | Camera snap countdown |
| Scene+0x434C..0x4354 | Vec3 | Camera offset from ball |
| Scene+0x87C | Camera* | Camera object pointer |
🔗 Related Documents
Case Study
Case Study: Hamsterball.exe
This case study shows how the generic RE playbook was applied to a real target: Hamsterball (2006, Raptisoft), PE32 i386, MSVC 2003.
1. Target Acquisition
- Windows installer and installed directory preserved in
originals/. - MD5:
7d25019366b8d7f55906325bd630d7fe. - Imports:
D3D8.dll,DINPUT8.dll,DSOUND.dll,BASS.dll,USER32.dll,KERNEL32.dll.
2. Static Recon
file Hamsterball.exe # PE32 executable (GUI) Intel 80386
strings Hamsterball.exe | grep -iE 'direct3d|d3d|dinput|dsound|bass'
pefile summary script # Image base 0x400000, entry 0xBB4C8
Strings revealed:
- Window class
AthenaWindow - Level paths
levels\\level1throughlevels\\level8, arena variants - Music format
.mo3 - Font and asset names
3. Ghidra Import and Naming
- Imported with default analyzers.
- Started GhidraMCP headless server.
- Applied 975+ renames from
docs/FUNCTION_MAP.md. - Final documentation coverage: 100%.
4. Subsystem Discovery
| Suspected | Evidence | Confirmed |
|---|---|---|
| D3D8 renderer | Direct3DCreate8 import, D3D8_RENDERING_PIPELINE.md |
Yes |
| DirectInput8 | DirectInput8Create import, INPUT_SYSTEM.md |
Yes |
| BASS audio | BASS_Init, BASS_MusicLoad, .mo3 strings |
Yes |
| MESHWORLD levels | Multiple .meshworld files, loader strings |
Yes |
| Vtable OOP | Constructor vtable assignments, destructors restoring base vtables | Yes |
5. Struct Recovery
Key structs recovered using the playbook:
| Struct | Evidence | Size |
|---|---|---|
Ball |
Ball_ctor2 allocates 0xC98, vtable 0x4CF3A0 |
0xC98 |
Scene |
Scene_dtor iterates nested lists |
0x47AC+ |
CollisionMesh |
Allocated via operator_new(0xCB0), owner back-pointer +0x10 | 0xCB0 |
App |
App_Initialize_Full constructs singleton |
0x918+ |
6. Verification Story: App+0x5DC
Initial claim: App+0x5DC = Scene* currentScene
Verification:
- Zero raw-C references to
(int)this + 0x5DCin App functions. - Raw C in
Ball_UpdateshowedScene+0x5DCused as a player-data array. - Correct conclusion:
App→Scenepath remained unverified; onlyBall+0x14 → Scenewas proven.
This demonstrates the rule: every offset is guilty until proven innocent by raw decompilation.
7. Dynamic Analysis
- Ran original under Wine + Xvfb + d3d8to9 + llvmpipe.
- Observed camera sign discrepancy across levels.
- Confirmed texture behavior differences between loaded and programmatic textures.
8. Outputs
docs/FUNCTION_MAP.md— 3,781 named functions.analysis/ghidra/structs/*.h— C struct definitions.docs/*SYSTEM.md— subsystem documentation.- Working D3D8 reimplementation in
reimpl/.
🔗 Related Documents
Catapult System
Hamsterball Catapult System — Complete Reverse Engineering
Overview
Catapults are level objects that launch the ball when it touches a specific collision surface. They appear in Level4 (Tower Race) and the Dizzy Arena. The catapult mesh is loaded from Levels/Level4-Catapult.MESHWORLD, which contains a single entity name: E:CATAPULTBOTTOM.
Why your global-spawn catapults are "hollow": Catapults are NOT standalone objects — they require a CollisionLevel child object with a spatial tree, the mesh must be registered with the scene's collision system, the catapult must be added to TWO AthenaLists on the scene (general + catapult-specific), and the launch is triggered by the collision dispatcher (TowerCollisionEvents / HandleArenaCollisionEvents) matching the collision entry's entity name. If you just operator_new + Catapult_ctor without going through CreateLevelObjects, you get a visual shell with no collision registration and no event-name association.
Class Hierarchy
SpriteAnim (base)
└─ Level (Level_ctor @ 0x00461740, size ~0x10D0)
└─ Stands (Stands_ctor @ 0x00462850)
└─ Catapult (Catapult_ctor @ 0x00437E10, alloc size = 0x1108 bytes)
Vtable
The Catapult vtable is at 0x004D5AD0 (referenced as PTR_MeshNode_Level_DeleteDtor3 in Ghidra). Key slots:
| Vtable Offset | Address | Function |
|---|---|---|
| +0x00 | 0x0043E5E0 | Gear_Vec3List_DeletingDtor (dtor) |
| +0x58 | — | SceneObj_CallUpdate (inherited, updates render matrix) |
| +0x54 | — | SceneObj_CallRender (inherited, renders mesh) |
| +0x2C (slot 11) | 0x0043E600 | Catapult_Update |
| +0x30 (slot 12) | 0x0045DE30 | LoadMeshWorld (inherited) |
| +0x58 (slot 22) | 0x0043EA50 | Catapult_Vec3List_DeletingDtor |
| +0x5C (slot 23) | 0x0043EA70 | Catapult_Render |
Note: The vtable at
0x004D4F98(set in Catapult_ctor) is the construction vtable used during C++ construction. The real runtime vtable at0x004D5AD0is set later by theStands_CtorCollision/CollisionLevel_ctorWithLevelchain. The data refs at0x004D5AFC(Catapult_Update) and0x004D5B5C(Catapult_Render) confirm the runtime vtable.
Catapult Struct Layout (size 0x1108 bytes)
All offsets are byte offsets from the catapult object pointer.
| Offset | Type | Field | Description |
|---|---|---|---|
| +0x000 | void** | vtable | Points to Catapult vtable |
| +0x008 | void* | mesh_ptr | MeshWorld/SceneObject mesh data (set by Level_ctor → SceneObject_BaseInit) |
| +0x00D | byte | has_mesh_flag | Set to 0 by Level_ctor |
| +0x018 | AthenaList | spatial_list | SpatialTree clone list (from Stands_ctor) |
| +0x430 | byte | flag_430 | Collision flag (copied from parent) |
| +0x431 | byte | flag_431 | Set to 1 in Stands_ctor (marks as "stands" type) |
| +0x434 | void* | timer_obj | Timer object (allocated in Stands_ctor) |
| +0x47C | void* | parent_level | Pointer back to the parent level/scene (set in Stands_ctor) |
| +0x488 | AthenaList | list_488 | Initialized in Level_ctor |
| +0x8A0 | AthenaList | list_8A0 | Initialized in Level_ctor |
| +0xCB8 | AthenaList | list_CB8 | Initialized in Level_ctor |
| +0x10D0 | void* | scene_ptr | Pointer to the Board/Scene (passed as param_1 to Catapult_ctor) |
| +0x10D4 | void* | collision_level | CollisionLevel child object (allocated 0x10D0 bytes, CollisionLevel_ctorWithLevel) |
| +0x10D8 | float[3] | position | XYZ position (set from param_4 in CreateLevelObjects) |
| +0x10E4 | int | pad | Set to 0 |
| +0x10E8 | float | launch_dir_y | Initial value = -1.0 (0xBF800000) — negative Y = downward initial direction |
| +0x10EC | void* | launch_ball_ptr | Ball to launch (set by TowerCollisionEvents when E:CATAPULTBOTTOM fires) |
| +0x10F0 | float | launch_timer | Launch countdown timer (set to 0 initially, set to 50.0 by Catapult_Launch, decremented each frame) |
| +0x10F4 | float | launch_decrement | Set to 50.0 (0x42480000) by Catapult_Launch — wait, that's wrong. |
| +0x10F8 | AthenaList | ball_list | List of balls currently on the catapult (AthenaList, initialized in ctor). Each entry is 8 bytes: [ball_ptr, tick_counter]. Used by Catapult_AddObjectConditional (N:ONGEAR). Tick counter starts at 10, decremented each frame by Catapult_Update, entry freed when it reaches 0. Counter resets to 10 on every frame of continued contact (grace period, not carry limit). |
| +0x10FC | int | ball_list_count | Count of balls in ball_list |
| +0x1100 | byte | active_flag | Set to 1 in CreateLevelObjects (marks catapult as active) |
| +0x1104 | float | launch_force | Initial value = 17.0 (0x41880000) — upward launch velocity |
| +0x1108 | AthenaList | object_list | Object list (used by Catapult_AddObjectConditional) |
| +0x110C | int | object_list_count | Count for object_list |
| +0x1510 | byte | conditional_flag | Checked by Catapult_AddObjectConditional (must be non-zero) |
Important: The
0x1104value of 17.0 is set inCatapult_ctorat*(float*)(this + 0x1104) = 0x41880000. This is the launch velocity applied to the ball.
Rotator system note:
Catapult_Update(0x43E600) serves double duty — it handles both catapult launch arcs AND rotator/gear ball carrying. When used by the rotator system (viaRotator_AddBallat 0x43B6F0), the tick counter at entry+4 counts down from 10 and the rotation matrix is applied to the ball's position and velocity each frame. The counter resets to 10 on every frame of continued collision contact, so the ball stays on the rotator indefinitely while touching it, with a 10-frame grace period after leaving. Seedocs/physics/COLLISION_SYSTEM_DEEP.md→ "Rotator System" for full details.
Creation Process
Step 1: Level Loading
When a level is loaded (e.g., Scene_LoadLevel4 @ 0x0040D6D0):
MeshWorld_ctorloads the main level geometry from"levels\\level4"→ stored atScene+0x22B(= scene+0x8AC offset, param_1[0x22b])CollisionLevel_ctorWithLevelcreates a collision level from the MeshWorld → stored atScene+0x22CLevel_InitScenesets up the scene- The level's
.MESHWORLDfile is loaded byLevel_LoadMeshes(@ 0x00465860)
Step 2: Level_LoadMeshes — Entity Registration
Level_LoadMeshes (@ 0x00465860) iterates all objects in the MeshWorld:
-
For each object with an entity name (stored at
obj+0x864):- Creates a
MeshBuffer(0x874 bytes) viaCreateMeshBuffer - If name starts with
"N:"→ setsmeshbuffer+0x85D = 1(name flag) - If name starts with
"E:"→ setsmeshbuffer+0x85D = 1ANDmeshbuffer+0x863 = 1(event flag) - Copies the entity name string into
meshbuffer+0x219*4(= +0x864 offset) - Creates triangle collision data and appends to the meshbuffer's list
- Creates a
-
This is how
E:CATAPULTBOTTOMgets registered — the MeshWorld fileLevel4-Catapult.MESHWORLDcontains the stringE:CATAPULTBOTTOMat offset 13580, and Level_LoadMeshes creates a MeshBuffer with that entity name.
Step 3: CreateLevelObjects — Catapult Factory
CreateLevelObjects (@ 0x00412711) is the factory dispatcher. When a mesh name matches "CATAPULT" (via __strnicmp):
// From CreateLevelObjects @ 0x00412711, CATAPULT section:
if (__strnicmp(param_1, "CATAPULT", 8) == 0) {
// 1. Allocate 0x1108 bytes for the Catapult object
pvVar5 = operator_new(0x1108);
// 2. Call Catapult_ctor(this, scene_ptr, mesh_source)
pvVar5 = Catapult_ctor(pvVar5, this, *(int*)((int)this + 0x5848));
// 3. Mark as active
*(byte*)((int)pvVar5 + 0x1100) = 1;
// 4. Set position from param_4 (the mesh transform data)
*(float*)((int)pvVar5 + 0x10D8) = param_4[1]; // X
*(float*)((int)pvVar5 + 0x10DC) = param_4[2]; // Y
*(float*)((int)pvVar5 + 0x10E0) = param_4[3]; // Z
// 5. CRITICAL: Add to scene's general object list
AthenaList_Append((void*)((int)this + 0x2578), (int)pvVar5);
// 6. CRITICAL: Add to scene's catapult-specific list
AthenaList_Append((void*)((int)this + 0x584C), (int)pvVar5);
}
This is the key step you're missing. Two AthenaList_Append calls:
Scene+0x2578— the general level objects list (for rendering/updating)Scene+0x584C— the catapult-specific list (used by the collision dispatcher to find catapults)
Step 4: Catapult_ctor Internals
Catapult_ctor (@ 0x00437E10, __thiscall):
void* Catapult_ctor(void* this, void* scene_ptr, int mesh_source) {
// 1. Call parent: Stands_ctor(this, mesh_source)
// - Calls SpriteAnim_Ctor (base)
// - Calls Level_ctor → sets up AthenaLists, SceneObject
// - Clones SpatialTree nodes from mesh_source
Stands_ctor(this, mesh_source);
// 2. Set Catapult vtable
*(void***)this = &PTR_MeshNode_Level_DeleteDtor3_004D4F98;
// 3. Store scene pointer
*(void**)((int)this + 0x10D0) = scene_ptr;
// 4. Initialize position fields (will be overwritten by CreateLevelObjects)
*(float*)((int)this + 0x10D8) = 0; // X
*(float*)((int)this + 0x10DC) = 0; // Y
*(float*)((int)this + 0x10E0) = 0; // Z
// 5. Clear pad and launch direction
*(int*)((int)this + 0x10E4) = 0;
*(float*)((int)this + 0x10E8) = -1.0f; // Initial launch direction Y = -1
// 6. CRITICAL: Create CollisionLevel child object
void* col = operator_new(0x10D0);
col = CollisionLevel_ctorWithLevel(col, (int)this);
*(void**)((int)this + 0x10D4) = col;
// 7. Copy timer from parent and clear collision flag
*(void**)((int)col + 0x434) = *(void**)((int)this + 0x434);
*(byte*)(*(int*)((int)this + 0x10D4) + 0x431) = 0;
// 8. Clear launch flags
*(byte*)((int)this + 0x10F0) = 0; // launch_timer = 0 (not active)
*(byte*)((int)this + 0x10F8) = 0; // ball_list empty
*(byte*)((int)this + 0x1100) = 0; // active_flag = 0 (set to 1 later by CreateLevelObjects)
// 9. Set launch force
*(float*)((int)this + 0x1104) = 17.0f; // 0x41880000
}
Step 5: CollisionLevel_ctorWithLevel
CollisionLevel_ctorWithLevel (@ 0x00465080, __thiscall):
void* CollisionLevel_ctorWithLevel(void* this, int parent_level) {
// 1. Call Level_ctor with parent's mesh data
Level_ctor(this, *(void**)(parent_level + 4)); // mesh source
// 2. Set CollisionLevel vtable
*(void***)this = &PTR_Level_DeletingDtor2_004D9068;
// 3. CRITICAL: Load meshes (creates MeshBuffers with entity names)
Level_LoadMeshes(this, parent_level);
// This is where E:CATAPULTBOTTOM gets its collision triangles created
}
Collision Detection & Launch Trigger
How the Ball Hits the Catapult
The game's collision system (SpatialTree) detects when the ball intersects a collision triangle. When a collision entry is created, its entity name (at collision_obj+0x864) is checked by the collision dispatcher.
Race Mode: TowerCollisionEvents
TowerCollisionEvents (@ 0x0040DCD0, __thiscall):
void TowerCollisionEvents(void* this, int* ball, int* coll_obj) {
// Check entity name on the collision object
if (__stricmp(*(char**)(coll_obj[1] + 0x864), "E:CATAPULTBOTTOM") == 0) {
// Cooldown check: ball+0x202 prevents re-triggering
if (ball[0x202] < 1) {
ball[0x202] = 1000; // Set cooldown (frames)
// Iterate catapult list (Scene+0x43B8 in race mode)
int idx = AthenaList_NextIndex((int)this + 0x43B8);
for (each catapult in Scene+0x47C4 list) {
// Match collision mesh to catapult
if (catapult->collision_level (catapult+0x10D4) == *coll_obj) {
catapult->launch_ball_ptr (catapult+0x10EC) = ball;
Catapult_Launch(catapult); // 0x00434290
Sound_PlayChannel(*(scene+0x878)->sounds[0x464]); // catapult sound
}
}
}
}
// ... other collision handlers (OPENSESAME, TRAPDOOR, BITE, MACETRIGGER, etc.)
DispatchCollisionEvents(this, ball, coll_obj);
}
Arena Mode: HandleArenaCollisionEvents
HandleArenaCollisionEvents (@ 0x00412D57) handles the same E:CATAPULTBOTTOM collision but uses different scene offsets for the catapult list:
// In HandleArenaCollisionEvents, E:CATAPULTBOTTOM handler:
if (__stricmp(*(char**)(coll_obj[1] + 0x864), "E:CATAPULTBOTTOM") == 0) {
if (ball[0x202] < 1) {
ball[0x202] = 1000;
// ARENA catapult list: Scene+0x584C (instead of Scene+0x43B8)
// ARENA catapult items: Scene+0x5C58 (instead of Scene+0x47C4)
int idx = AthenaList_NextIndex((int)this + 0x584C);
for (each catapult in Scene+0x5C58 list) {
if (catapult+0x10D4 == *coll_obj) {
catapult+0x10EC = ball;
Catapult_Launch(catapult);
Sound_PlayChannel(*(scene+0x878)->sounds[0x464]);
}
}
}
}
Catapult_Launch
Catapult_Launch (@ 0x00434290, __fastcall):
void Catapult_Launch(int catapult) {
*(byte*)(catapult + 0x10F0) = 1; // Set launch active flag
*(float*)(catapult + 0x10F4) = 50.0f; // Set launch timer (0x32 = 50 as int,
// but stored as float 0x42480000 = 50.0)
}
This is extremely simple — it just sets two fields:
+0x10F0 = 1— marks the catapult as "launching"+0x10F4 = 50.0— countdown timer (decremented each frame by Catapult_Update)
Catapult_Update — The Launch Physics
Catapult_Update (@ 0x0043E600, __fastcall) is called every frame via the vtable. It:
1. Timer Countdown
// FLD [EBX+0x10F0] (load launch_timer)
// FSUB [EBX+0x10F4] (subtract decrement)
// FSTP [EBX+0x10F0] (store result)
catapult->launch_timer -= catapult->launch_decrement;
This counts down the launch timer. When launch_timer reaches 0 or below, the launch is complete.
2. Ball Position Transformation
Catapult_Update iterates through the ball list (catapult+0x10F8 AthenaList) and applies a matrix transformation to each ball:
// For each ball in the catapult's ball_list:
// 1. Calculate ball position relative to catapult (ball_pos - catapult_pos)
// 2. Apply rotation matrix (built from launch_timer value)
// 3. Apply scaling (Gfx_ScaleY, Gfx_ScaleX, Gfx_ScaleZ)
// 4. Transform ball position using the matrix
// 5. Update ball position (ball+0x164/X, ball+0x168/Y, ball+0x16C/Z)
// 6. Transform ball velocity (ball+0x1A4 → physics_obj+0xCA4/CAC)
The key physics operations:
- Ball position is at
ball+0x164(X),ball+0x168(Y),ball+0x16C(Z) - Ball velocity is at
ball->physics_obj (ball+0x1A4) + 0xCA4(velX),+0xCA8(velY),+0xCAC(velZ) - The matrix is built using
Timer_Init+Gfx_Scale*calls, which construct a transformation matrix - The
launch_forceat+0x1104(17.0) determines the upward velocity
3. Object Aging (Time-Out)
Catapult_Update also manages objects in the ball_list with a countdown:
// For each entry in the list:
// entry[1] -= 1; (decrement timer)
// if (timer < 1) { remove entry; free memory; }
This means balls stay on the catapult list for a limited number of frames (initial value = 10, set by Catapult_AddObjectConditional).
Catapult_AddObjectConditional
Catapult_AddObjectConditional (@ 0x0043E9C0, __thiscall):
void Catapult_AddObjectConditional(void* this, int ball_ptr) {
// Only proceed if catapult is active (flag at +0x1510)
if (*(char*)((int)this + 0x1510) != '\0') {
// Check if ball is already in the list
for (each entry in ball_list (this+0x10F8)) {
if (entry->ball == ball_ptr) {
entry->timer = 10; // Reset timer
return;
}
}
// Add new entry
int* entry = operator_new(8);
entry[0] = ball_ptr; // ball pointer
entry[1] = 10; // frames to stay on list
AthenaList_Append(this+0x10F8, entry);
}
}
This is called from an unnamed function at ~0x004184D9 (likely a per-frame collision check that adds balls that are near/on the catapult to the ball list).
Catapult_Render
Catapult_Render (@ 0x0043EA70, __fastcall):
// 1. Calculate oscillation using Wave_Sin
float wave = Wave_Sin(PTR_PTR_004F7188, catapult->render_time (+0x53F));
Gfx_ScaleZ(wave * _DAT_004D5D20);
// 2. Set position
Gfx_SetPosition(catapult_pos_x, catapult_pos_y, catapult_pos_z);
// 3. Update render time
catapult->render_time (+0x53F) += catapult->render_speed (+0x540);
// 4. Call inherited render (vtable+0x58 = CallUpdate, vtable+0x54 = CallRender)
(**(code**)(*catapult + 0x58))(); // SceneObj_CallUpdate
(**(code**)(*catapult + 0x54))(); // SceneObj_CallRender
// 5. Age and free expired objects from the render list
The Wave_Sin call creates the visual oscillation/animation of the catapult platform.
MESHWORLD File: Level4-Catapult.MESHWORLD
- File:
Levels/Level4-Catapult.MESHWORLD(14,370 bytes) - Entity name:
E:CATAPULTBOTTOM(at file offset 13,580) - This is the ONLY entity name in the file — no
N:names, no otherE:events - The mesh name "CATAPULT" is what
CreateLevelObjectsmatches with__strnicmp
The catapult's collision geometry (the "bottom" surface the ball touches) is tagged with E:CATAPULTBOTTOM. When the SpatialTree detects a collision between the ball and any triangle belonging to this mesh, the collision entry's +0x864 field points to the string "E:CATAPULTBOTTOM", which the collision dispatcher matches.
Scene List Offsets Summary
| Context | General Objects List | Catapult List | Catapult Items Array |
|---|---|---|---|
| Race (TowerCollisionEvents) | Scene+0x2578 | Scene+0x43B8 | Scene+0x47C4 |
| Arena (HandleArenaCollisionEvents) | Scene+0x2578 | Scene+0x584C | Scene+0x5C58 |
Note: In race mode,
Scene+0x43B8is the catapult AthenaList andScene+0x47C4is its internal items array. In arena mode,Scene+0x584CandScene+0x5C58serve the same purpose. The CreateLevelObjects factory adds toScene+0x584Cfor both modes — the arena offsets appear to be the primary ones, and TowerCollisionEvents uses different offsets that may be set up during race initialization.
Why Your Global Spawns Are Hollow — And How to Fix Them
What you're probably doing:
// WRONG: Just creating the object
void* cat = operator_new(0x1108);
Catapult_ctor(cat, scene, mesh_source);
// ... set position ...
// Catapult renders but has NO collision and NO launch behavior
What you need to do:
// CORRECT: Full creation pipeline
// 1. Allocate and construct
void* cat = operator_new(0x1108);
Catapult_ctor(cat, scene, mesh_source);
// 2. Mark as active
*(byte*)((int)cat + 0x1100) = 1;
// 3. Set position
*(float*)((int)cat + 0x10D8) = x;
*(float*)((int)cat + 0x10DC) = y;
*(float*)((int)cat + 0x10E0) = z;
// 4. CRITICAL: Add to scene's general object list (for rendering/updating)
AthenaList_Append((void*)((int)scene + 0x2578), (int)cat);
// 5. CRITICAL: Add to scene's catapult-specific list (for collision dispatch)
// Use Arena offsets (0x584C / 0x5C58) — these are what CreateLevelObjects uses
AthenaList_Append((void*)((int)scene + 0x584C), (int)cat);
// 6. The CollisionLevel child (cat+0x10D4) was created by Catapult_ctor
// and Level_LoadMeshes registered the E:CATAPULTBOTTOM entity name.
// BUT: the collision mesh must be registered with the scene's SpatialTree
// for the ball to actually collide with it.
The Missing Piece: Collision Registration
The CollisionLevel at cat+0x10D4 has its own SpatialTree (cloned from the parent in Stands_ctor). However, for the ball's collision system to detect intersections with the catapult's mesh, the catapult's collision triangles must be registered in the scene's main SpatialTree.
In the normal level loading flow, this happens because:
Level_LoadMeshescreates MeshBuffers with entity names and collision triangles- The SpatialTree in the CollisionLevel stores these triangles
- When
TowerCollisionEvents/HandleArenaCollisionEventscheckscatapult+0x10D4 == *coll_obj, it's matching the collision object that was created from the CollisionLevel's mesh data
For a global spawn, you need to ensure:
- The CollisionLevel's mesh is loaded (Catapult_ctor does this via
CollisionLevel_ctorWithLevel) - The collision triangles are registered with the scene's SpatialTree
- The catapult is added to the correct scene list so the collision dispatcher can find it
The simplest approach: call CreateLevelObjects with a mesh named "CATAPULT" — this does everything correctly. If you want to spawn at runtime, replicate all the steps that CreateLevelObjects does (allocation, construction, position set, both AthenaList_Append calls).
Related Functions Reference
| Address | Name | Description |
|---|---|---|
| 0x00437E10 | Catapult_ctor | Constructor (thiscall: this, scene, mesh_source) |
| 0x0043E600 | Catapult_Update | Per-frame update: timer countdown + ball physics |
| 0x0043EA70 | Catapult_Render | Per-frame render: oscillation + mesh render |
| 0x00434290 | Catapult_Launch | Sets launch flag + timer (called on E:CATAPULTBOTTOM) |
| 0x0043E9C0 | Catapult_AddObjectConditional | Adds ball to catapult's ball_list |
| 0x0043EA50 | Catapult_Vec3List_DeletingDtor | Destructor |
| 0x00437820 | Catapult_Vec3List_Level_Dtor | Level destructor |
| 0x00412711 | CreateLevelObjects | Factory: matches "CATAPULT" mesh name |
| 0x0040DCD0 | TowerCollisionEvents | Race collision dispatcher (E:CATAPULTBOTTOM) |
| 0x00412D57 | HandleArenaCollisionEvents | Arena collision dispatcher (E:CATAPULTBOTTOM) |
| 0x00462850 | Stands_ctor | Parent constructor |
| 0x00465080 | CollisionLevel_ctorWithLevel | Creates CollisionLevel + loads meshes |
| 0x00461740 | Level_ctor | Grandparent constructor |
| 0x00465860 | Level_LoadMeshes | Loads meshes + registers entity names |
| 0x00461460 | SceneObject_BaseInit | Initializes SceneObject mesh data |
| 0x0045DF80 | SceneObject_CallUpdate | Inherited vtable: update render matrix |
| 0x0045DF90 | SceneObject_CallRender | Inherited vtable: render mesh |
String References
| Address | String | Usage |
|---|---|---|
| 0x004CF99C | "CATAPULT" |
Mesh name matched by CreateLevelObjects |
| 0x004CF9A4 | "E:CATAPULTBOTTOM" |
Collision event name (stricmp in dispatchers) |
| 0x004D09C6 | "Levels\\Level4-Catapult" |
MESHWORLD file path |
| 0x004D2DEF | "sounds\\catapult" |
Sound file path |
🔗 Related Documents
Collision Dumper
Collision Dumper
Dumps all fields of every collision entry from the game's PhysicsObject to a text file, for reverse-engineering unknown struct fields.
What It Does
- Hooks
Ball_Update(0x405E00) with a 5-byte JMP detour - After each
Ball_Updatecompletes, walks the PhysicsObject's collision entry list - Dumps 0x80 bytes (32 DWORDs) per entry as hex + float, with known-field annotations
- Throttled to ~1 dump per second to avoid flooding
- Output:
collision_dump.txtin the game directory
Entry Fields (Known)
| Offset | Type | Field |
|---|---|---|
| +0x00 | int32 | type (1=ball-ball, 2=wall, 5=floor) |
| +0x0C | void* | other_ball (type==1 only) |
| +0x20 | float | normal_x |
| +0x24 | float | normal_y |
| +0x28 | float | normal_z |
| +0x2C | float | collision_pt |
| +0x30 | float | normal2_x |
| +0x34 | float | normal2_y |
| +0x38 | float | normal2_z |
| +0x64 | int32 | collision_id |
All other offsets are UNKNOWN — that's what this dumper is designed to test.
Build
i686-w64-mingw32-gcc -shared -o bass.dll collision_dumper.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc -Wl,--add-stdcall-alias
Install
- Rename original
bass.dll→bass_real.dllin the Hamsterball directory - Copy this compiled
bass.dllto the same directory - Launch
Hamsterball.exe - A MessageBox confirms the hook is installed
- Play the game — collide with walls and balls
- Check
collision_dump.txtfor output
🔗 Related Documents
Collision Event System
Hamsterball Collision Event System
Architecture
The collision event system uses a 2-tier dispatcher chain. The scene's vtable determines which top-level handler runs:
Ball Update (ball->vtable[0x10], called from Scene_UpdateBallsAndState 0x41B540)
└─ Geometric collision detected by Ball_AdvancePositionOrCollision (0x4564C0)
└─ Event dispatch based on object name string at collObj+0x864
├─ ExpertCollisionEvents (0x40E6A0) ← Rumble arenas (vtable-driven)
│ └─ DispatchCollisionEvents (0x40C5D0) ← Shared base (ALL events)
└─ TowerCollisionEvents (0x40DCD0) ← Race levels (vtable-driven)
└─ DispatchCollisionEvents (0x40C5D0) ← Shared base (ALL events)
Arena and Level handlers are parallel, not chained — they never call each other. Both delegate to DispatchCollisionEvents for universal events. Ball_AdvancePositionOrCollision handles only geometric collision detection (velocity integration, mesh intersection) and does NOT dispatch events itself.
Event Dispatch Table
Tier 1: ExpertCollisionEvents (0x40E6A0) — Arena Events
Domain: Rumble arena multiplayer events. Most events gated behind App+0x23C (multiplayer flag).
| Event | Effect | Gated |
|---|---|---|
| E:CALLHAMMER | Spawn bonk popup | Yes |
| E:HAMMERCHASE | Start hammer chase | Yes |
| E:ALERTSAW1 | Saw blade 1 alert mode | Yes |
| E:ALERTSAW2 | Saw blade 2 alert mode | Yes |
| E:ACTIVATESAW1 | Saw blade 1 full active | Yes |
| E:ACTIVATESAW2 | Saw blade 2 full active | Yes |
| E:ALERTJUDGES | Reset all judge objects | No |
| E:SCORE(N) | Set score display to N | No |
| E:JUMP | Ball bounce + sound + 200pts | No |
| E:BELL | Extra time (+500) + popup | No |
After processing: delegates to DispatchCollisionEvents (not to TowerCollisionEvents — Arena and Level are parallel).
Tier 2: TowerCollisionEvents (0x40DCD0) — Level Events
Domain: Race level mechanical objects.
| Event | Effect | Details |
|---|---|---|
| E:CATAPULTBOTTOM | Launch catapult | Ball freeze=1000, catapult stores ball ref |
| E:OPENSESAME | Open all trapdoors | Opens first trapdoor in list |
| N:TRAPDOOR | Activate matching trapdoor | Matches by object ID or secondary ID |
| E:BITE | Set damage | Timer=25.0 (0x41C80000), clear counter |
| E:MACETRIGGER | Activate all maces | Sets each mace active flag at +0x10F0 |
| N:MACE | Ball bounces off mace | Only if mace swinging (not 0x42A00000) and not already hit |
After processing: delegates to DispatchCollisionEvents.
Base Tier: DispatchCollisionEvents (0x40C5D0) — Shared Base Handler
Domain: All game events (base handler, processes everything).
| Event | Effect | Key Details |
|---|---|---|
| N:SECRET | Mark rotator triggered | Secret found marker |
| N:UNLOCKSECRET | Check arena unlock | May unlock new arena |
| E:NODIZZY | Anti-dizzy zone | Parses <TIME>N</TIME> tags, grants dizzy immunity |
| E:SAFESWITCH | Copy switch data | Data in parentheses → ball+0xC2C |
| E:LIMIT | Arena boundary | Tracks completions per player (0-3) |
| E:BREAK | Ball bounce | Calls ball vtable[0x20] |
| E:JUMP | Ball jump | 3D sound, impact=10, force=0.025, +200pts |
| E:ACTION | Score award | <ONCE>TRUE</ONCE> prevents repeat, <SCORE>N</SCORE> with difficulty mod |
| E:TRAJECTORY | Set trajectory | Parses <X>, <Y>, <Z> XML tags |
| N:NOCONTROL | Disable input | 10 frame freeze |
| N:WATER | Water effect | Sets in-water flag + 10 frame timer |
| N:TARPIT | Tar slowdown | 3D sound, marks tar state, clears velocity |
| N:GOAL | Finish race | Plays "Goal!" music, sets finish flags, records time |
| N:MOUSETRAP | Ball deflect | Normalizes direction × speed (0x4CF370), finds rotator |
| DROPIN | Pipe drop-in | Sound + +200pts if speed > threshold |
| PIPEBONK | Pipe bonk | Random sound from 3, +100pts, 10 frame cooldown |
| POPOUT | Pipe pop-out | Sound + +100pts, 50 frame cooldown |
Event Name Format
Event names are stored in collision objects at object+0x864 (string pointer).
Two prefixes:
- N: = Named/physical object (something the ball interacts with physically)
- E: = Event/trigger (something that activates on ball contact)
Some events have XML-style tag parameters:
E:NODIZZY<TIME>500</TIME>
E:ACTION<ONCE>TRUE</ONCE><SCORE>500</SCORE>
E:TRAJECTORY<X>1.0</X><Y>0.5</Y><Z>-1.0</Z>
E:SCORE250
E:SAFESWITCH(my_data)
Tags are parsed by MWParser_ReadTag which extracts name-value pairs.
Ball State Offsets Used by Events
| Ball Offset | Type | Used By |
|---|---|---|
| +0x059/5A/5B | float | Position X/Y/Z (for 3D sound) |
| +0x0A7/0A8 | float | Jump force direction/mode |
| +0x0B3 | byte | In-tar flag |
| +0x0B4 | float | Tar entry Y position |
| +0x0B6 | int | Water timer (10 frames) |
| +0x0CB | AthenaList | ONCE event tracker (prevents re-trigger) |
| +0x1DA | byte | Active flag (cleared on LIMIT/TARPIT) |
| +0x1F2 | int | DROPIN cooldown |
| +0x1F5 | int | Unknown cooldown (50 frames) |
| +0x1F7 | int | Impact counter (jump/bounce cooldown) |
| +0x202 | int | Freeze counter (input disabled while > 0) |
| +0x2D5 | byte | In-water flag |
| +0x2E9 | byte | LIMIT hit flag (⚠ sticky — never cleared within Ball_Update, NOT a ground flag) |
| +0x30B | byte | SAFESWITCH state |
| +0xC2C | char[] | SAFESWITCH data buffer |
| player_index*0xA0 | per-player | Score, timer, finish state offsets |
App Offsets Used by Events
| App Offset | Type | Description |
|---|---|---|
| +0x0210 | char* | Activity log string ("Reach Goal 1"..."Update") |
| +0x0220 | ptr | Player profile (checks +0x10/11 for multiplayer) |
| +0x0234 | byte | Some flag |
| +0x023C | int | Multiplayer flag (gates arena events) |
| +0x0294-2C4 | float* | Race timer positions (4 players × mirror/normal) |
| +0x0460 | int | DROPIN sound handle |
| +0x0464 | int | Catapult sound handle |
| +0x0468 | int | POPOUT sound handle |
| +0x046C+ | int[3] | PIPEBONK sound handles (3 random choices) |
| +0x0484 | int | TARPIT sound handle |
| +0x049C | int | JUMP sound handle |
| +0x04CC | int | Unknown sound handle |
| +0x053C | void* | Music player handle |
| +0x05CC+ | per-player | Score timer position |
| +0x05D6+ | per-player | Finish flag (bool) |
| +0x05E4+ | per-player | Accumulated score (float) |
| +0x05EC+ | per-player | Extra time (bell, 500 units) |
| +0x05F0+ | per-player | Another finish flag |
| +0x05FC+ | per-player | Race finished flag |
| +0x05E8 | int | Tournament time record |
| +0x090C | int | Tournament scene manager (optional) |
Per-Player Offset Calculation
Many app offsets use the pattern: app + playerIndex * 0xA0 + baseOffset
Ball player index is at ball[6] (param_1[6]).
Examples:
- Score:
app + playerIdx*0xA0 + 0x5E4 - Timer:
app + playerIdx*0xA0 + 0x5CC - Finish:
app + playerIdx*0xA0 + 0x5D6 - Extra time:
app + playerIdx*0xA0 + 0x5EC
Sound System Integration
3D positioned sounds use Sound_Play3D(handle, x, y, z) where position comes
from the ball. Non-positioned sounds use Sound_PlayChannel(handle).
Each sound event has a cooldown timer (impact/freeze counter) to prevent
re-triggering every frame while the ball is inside the trigger volume.
Arena Object Storage
Arena-specific objects are stored in the Board/Level:
| Level Offset | List | Objects |
|---|---|---|
| +0x436C | BonkList | Hammer/bonk objects |
| +0x4370 | SawList1 | Saw blade 1 |
| +0x4374 | SawList2 | Saw blade 2 |
| +0x43B8 | CatapultList | Catapults (count at +0x43BC, array at +0x47C4) |
| +0x47D0 | TrapdoorList | Trapdoors (count at +0x47D4, array at +0x4BDC) |
| +0x4BBC | JudgeList | Judges (count at +0x4BC0, array at +0x4FC8) |
| +0x4FD4 | BellList | Bell objects |
| +0x5000 | MaceList | Maces (count at +0x5004, array at +0x540C) |
Each object in these lists has its ID at +0x10D4 which is compared against
the collision object's ID to find the matching game object.
🔗 Related Documents
Collision Handler Complete Map
Collision Handler Complete Mapping (Verified June 2026)
CORRECTED: All 30 board vtable[0x1D] entries verified from binary via GhidraMCP read_memory.
Previous docs combined Sky/Neon and missed Glass Race, Neon Race, Neon Arena, and Glass Arena handlers.
The REF_LOADING_SYSTEM.md had Race/Arena constructor tables SWAPPED.
Summary
- 15 race boards + 15 arena boards = 30 total boards
- 26 unique handler functions (some shared)
- 4 handlers were newly discovered in this pass: NeonRace, NeonArena, GlassRace, GlassArena
- 1 handler was mislabelled: 0x410D00 was called "NeonCollisionEvents" but is actually SkyCollisionEvents
- Previous docs combined Sky+Neon and Glass+Impossible into single entries
Race Board Handlers (15)
| # | Race Level | Constructor | Vtable | vtable[0x1D] | Handler Name | Events |
|---|---|---|---|---|---|---|
| 1 | WarmUp | 0x41CA40 | 0x4D04A8 | 0x40C5D0 | DispatchCollisionEvents (base) | (base only — no override) |
| 2 | Beginner | 0x4200E0 | 0x4D1098 | 0x4111E0 | BeginnerCollisionEvents | N:BUMPER |
| 3 | Intermediate | 0x41CB20 | 0x4D05A0 | 0x40D340 | IntermediateCollisionEvents | N:BRIDGE |
| 4 | Dizzy | 0x41D060 | 0x4D0890 | 0x40D500 | DizzyCollisionEvents | N:WATERWHEEL, N:WHEELEMBED, N:SWIRL |
| 5 | Tower | 0x41E340 | 0x4D0A08 | 0x40DCD0 | TowerCollisionEvents | E:CATAPULTBOTTOM, E:OPENSESAME, N:TRAPDOOR, E:BITE, E:MACETRIGGER, N:MACE |
| 6 | Up | 0x420390 | 0x4D11A0 | 0x4119B0 | UpCollisionEvents | E:HELPINERTIA, E:UNHELPINERTIA, E:VACPOPOUT, N:SPEEDCYLINDER, N:EXTRATIME |
| 7 | Neon | 0x424440 | 0x4D1DF0 | 0x416CA0 | NeonRaceCollisionEvents (NEW) | N:NEONPLATFORM, E:ZOOP, E:LIGHTSOFF, E:LIGHTSON |
| 8 | Expert | 0x41EA40 | 0x4D0B00 | 0x40E6A0 | ExpertCollisionEvents | E:CALLHAMMER, E:HAMMERCHASE, E:ALERTSAW1/2, E:ACTIVATESAW1/2, E:ALERTJUDGES, E:SCORE, E:JUMP, E:BELL |
| 9 | Odd | 0x41ED80 | 0x4D0BC0 | 0x40ED30 | OddCollisionEvents | E:GRAVITY, N:JUMPFIRST, N:JUMPSECOND, E:SHRINK, E:GROWSOUND, E:GROW, E:DROPLIFT, E:PIPERANDOM, E:LIMIT, E:LIMITX, E:LIMITZ, E:LIMITPIPE1, E:LIMITPIPE2, E:SWALLOW |
| 10 | Toob | 0x41F4B0 | 0x4D0E78 | 0x410020 | ToobCollisionEvents | E:ALERTSAW2, E:BRANCH(A/B), N:SPINNY, N:SAWTEETH, N:BUMPER |
| 11 | Wobbly | 0x41F110 | 0x4D0D38 | 0x40F9A0 | WobblyCollisionEvents | N:SQUAREWOBBLY, N:WAVY |
| 12 | Glass | 0x424A90 | 0x4D1F90 | 0x417760 | GlassRaceCollisionEvents (NEW) | N:GLASS, N:TENBONUS1, N:TENBONUS2 |
| 13 | Sky | 0x41F930 | 0x4D0FC8 | 0x410D00 | SkyCollisionEvents (renamed from "NeonCollisionEvents") | E:PEGS, E:TRAPPOP, E:NOPEGS, E:HEATON, E:HEATOFF, E:LIMIT |
| 14 | Master | 0x4206D0 | 0x4D12B0 | 0x412850 | MasterCollisionEvents | N:SPINNER, N:BUMPER, E:LAUNCH, E:CALLHAMMER, E:HAMMERCHASE, E:CATAPULTBOTTOM |
| 15 | Impossible | 0x424C20 | 0x4D21C0 | 0x418360 | ImpossibleCollisionEvents | N:BOUNCE, N:ONROTATOR, N:ONGEAR, E:HELPINERTIA, E:UNHELPINERTIA |
Arena (Rumble) Board Handlers (15)
| # | Arena Level | Constructor | Vtable | vtable[0x1D] | Handler Name | Events |
|---|---|---|---|---|---|---|
| 1 | Warmup Arena | 0x4224A0 | 0x4D1428 | 0x413BD0 | SinkPlatformArenaCollisionEvents (shared) | DN:SINKPLATFORM only |
| 2 | Beginner Arena | 0x422550 | 0x4D14F0 | 0x413DF0 | BeginnerArenaCollisionEvents | N:BUMPER, DN:SINKPLATFORM |
| 3 | Intermediate Arena | 0x4226E0 | 0x4D15C0 | 0x413BD0 | SinkPlatformArenaCollisionEvents (shared) | DN:SINKPLATFORM only |
| 4 | Dizzy Arena | 0x422790 | 0x4D1680 | 0x414350 | DizzyArenaCollisionEvents | N:SWIRL, DN:SINKPLATFORM |
| 5 | Tower Arena | 0x4228C0 | 0x4D1740 | 0x414570 | TowerArenaCollisionEvents | E:CATAPULTBOTTOM, DN:SINKPLATFORM |
| 6 | Up Arena | 0x422B10 | 0x4D17F8 | 0x413BD0 | SinkPlatformArenaCollisionEvents (shared) | DN:SINKPLATFORM only |
| 7 | Neon Arena | 0x424860 | 0x4D1EC8 | 0x417490 | NeonArenaCollisionEvents (NEW) | N:BUMP, DN:SINKPLATFORM |
| 8 | Expert Arena | 0x423060 | 0x4D18C8 | 0x413BD0 | SinkPlatformArenaCollisionEvents (shared) | DN:SINKPLATFORM only |
| 9 | Odd Arena | 0x423220 | 0x4D1980 | 0x414DA0 | OddArenaCollisionEvents | E:GRAVITY(TYPE), DN:SINKPLATFORM |
| 10 | Toob Arena | 0x4234E0 | 0x4D1A40 | 0x415010 | ToobArenaCollisionEvents | N:BUMPER, DN:SINKPLATFORM |
| 11 | Wobbly Arena | 0x423690 | 0x4D1B18 | 0x415540 | WobblyArenaCollisionEvents | N:SQUAREWOBBLY, DN:SINKPLATFORM |
| 12 | Glass Arena | 0x424B60 | 0x4D2048 | 0x417EB0 | GlassArenaCollisionEvents (NEW) | N:GLASS, DN:SINKPLATFORM |
| 13 | Sky Arena | 0x423BF0 | 0x4D1BD8 | 0x413BD0 | SinkPlatformArenaCollisionEvents (shared) | DN:SINKPLATFORM only |
| 14 | Master Arena | 0x424380 | 0x4D1C80 | 0x416140 | WarmupArenaCollisionEvents | E:LAUNCH, DN:SINKPLATFORM |
| 15 | Impossible Arena | 0x424EC0 | 0x4D2298 | 0x418600 | ImpossibleArenaCollisionEvents | N:BOUNCE, DN:SINKPLATFORM |
Corrections from Previous Documentation
1. Sky vs Neon were combined into one entry
WRONG: Old docs had "Sky/Neon | 0x4D0FC8 | NeonCollisionEvents" as a single entry.
CORRECT: Sky Race (0x4D0FC8) and Neon Race (0x4D1DF0) are separate boards with separate handlers:
- Sky Race → 0x410D00 (SkyCollisionEvents, was mislabelled "NeonCollisionEvents")
- Neon Race → 0x416CA0 (NeonRaceCollisionEvents, was COMPLETELY MISSING)
2. Neon Arena handler was missing
WRONG: Old docs had "Sky/Neon Arena | 0x4D1BD8 | SinkPlatformArenaCollisionEvents" combining both.
CORRECT: Sky Arena (0x4D1BD8) uses the shared handler, but Neon Arena (0x4D1EC8) has its OWN handler:
- Sky Arena → 0x413BD0 (SinkPlatformArenaCollisionEvents, shared) ✓ (was correct)
- Neon Arena → 0x417490 (NeonArenaCollisionEvents, was COMPLETELY MISSING)
3. Glass Race handler was missing
WRONG: Old docs only listed "Glass | 0x4D2048 | GlassCollisionEvents" for the Glass Arena vtable.
CORRECT: Glass Race and Glass Arena are separate boards:
- Glass Race → vtable 0x4D1F90 → handler 0x417760 (GlassRaceCollisionEvents, was MISSING)
- Glass Arena → vtable 0x4D2048 → handler 0x417EB0 (GlassArenaCollisionEvents, was listed but as "GlassCollisionEvents")
4. 0x410D00 was mislabelled
WRONG: Called "NeonCollisionEvents" in old docs.
CORRECT: It is the Sky Race handler (LevelBoard_Sky_ctor at 0x41F930 sets vtable 0x4D0FC8, whose vtable[0x1D] = 0x410D00). Renamed to SkyCollisionEvents.
5. Impossible Race/Arena were correct (no change needed)
Old docs correctly had:
- Impossible Race → 0x418360 ✓
- Impossible Arena → 0x418600 ✓
6. REF_LOADING_SYSTEM.md constructor tables were swapped
WRONG: The doc labelled 0x422xxx constructors as "Race Board System" and 0x41Cxxx as "Arena Board System".
CORRECT: It's the OPPOSITE:
- 0x41Cxxx constructors (0x41CA40–0x41F930) create RACE boards ("Board (X)" + "X RACE")
- 0x422xxx constructors (0x4224A0–0x424EC0) create ARENA boards ("ArenaBoard (X)" + "X ARENA")
Verified by decompiling all 30 constructors and reading the board name strings.
4 Newly Discovered Handlers — Event Details
NeonRaceCollisionEvents @ 0x416CA0
__thiscall void(void *board, int *ball, int *collPair) — Neon Race board only.
| Event | Action |
|---|---|
N:NEONPLATFORM |
Lifter_PlaySound on colliding object |
E:ZOOP |
Sound_Play3D + cooldown (ball+0x7F0=100) |
E:LIGHTSOFF |
Sound + vtable4 on neon platform + Scene_RegisterObject + AthenaList_Append to board+0x2578 (update list) + increment light-off counter at board+0x4390 |
E:LIGHTSON |
Sound + vtable4 on neon platform + Scene_RegisterObject + decrement light-off counter; when counter reaches 0, set state=2 at mesh+0x10DC |
Falls through to: DispatchCollisionEvents (master collision dispatcher)
NeonArenaCollisionEvents @ 0x417490
__thiscall void(void *board, int *ball, int *collPair) — Neon Arena board only.
| Event | Action |
|---|---|
N:BUMP |
Bumper kick: reads velocity from physics_obj+0xCA4, scales by _DAT_004CF55C. If too slow: normalize to 5.0. If fast enough: normalize to 8.0 (different from Master's 12.0!). Writes back to physics velocity. |
DN:SINKPLATFORM |
Scene_StartCountdown (arena sink) |
Falls through to: DispatchCollisionEvents (master collision dispatcher)
GlassRaceCollisionEvents @ 0x417760
__thiscall void(void *board, int *ball, int *collPair) — Glass Race board only.
| Event | Action |
|---|---|
N:GLASS |
Sets ball+0xCDC = 15 (glass break effect counter, byte offset 0xCDC = DWORD index 0x317) |
N:TENBONUS1 |
Speed-gated time bonus. If ball speed > threshold (_DAT_004D0178) and not already triggered (board+0x438C==0): +1000 score (App+0x5EC + player_idx*0xA0), "EXTRA TIME:" ScoreObject popup, Sound_Play3D. Uses board+0x436C/4370/4374 for sound position. |
N:TENBONUS2 |
Same as TENBONUS1 but uses board+0x4378/437C/4380 for position and board+0x438D for triggered flag |
Falls through to: DispatchCollisionEvents (master collision dispatcher)
GlassArenaCollisionEvents @ 0x417EB0
__thiscall void(void *board, int *ball, int *collPair) — Glass Arena board only.
| Event | Action |
|---|---|
N:GLASS |
Sets ball+0xCDC = 15 (glass break effect counter) |
DN:SINKPLATFORM |
Scene_StartCountdown (arena sink) |
Falls through to: DispatchCollisionEvents (master collision dispatcher)
Shared Handler Summary
| Handler | Address | Shared By |
|---|---|---|
| DispatchCollisionEvents | 0x40C5D0 | WarmUp Race (base, no override) |
| SinkPlatformArenaCollisionEvents | 0x413BD0 | Warmup Arena, Intermediate Arena, Up Arena, Expert Arena, Sky Arena |
| MasterCollisionEvents | 0x412850 | Master Race only (NOT shared with Master Arena — arena uses WarmupArenaCollisionEvents) |
Note: Master Arena (0x4D1C80) uses WarmupArenaCollisionEvents (0x416140), NOT MasterCollisionEvents. Previous docs incorrectly claimed "Master Arena reuses the exact same handler as the Master race board." This was WRONG.
New Event Names Discovered
These event names were NOT in previous documentation:
| Event | Handler | Description |
|---|---|---|
N:NEONPLATFORM |
NeonRaceCollisionEvents | Neon platform interaction sound |
E:ZOOP |
NeonRaceCollisionEvents | 3D sound effect with cooldown |
E:LIGHTSOFF |
NeonRaceCollisionEvents | Turn off neon platform lights |
E:LIGHTSON |
NeonRaceCollisionEvents | Turn on neon platform lights |
N:BUMP |
NeonArenaCollisionEvents | Arena bumper (scale 5.0/8.0, different from N:BUMPER) |
N:TENBONUS1 |
GlassRaceCollisionEvents | Speed-gated +1000 time bonus (position 1) |
N:TENBONUS2 |
GlassRaceCollisionEvents | Speed-gated +1000 time bonus (position 2) |
DispatchCollisionEvents — Common Fallthrough
All 4 new handlers fall through to DispatchCollisionEvents (the master dispatcher at 0x40C5D0).
DispatchCollisionEvents is the master collision event dispatcher — it parses
<TIME>value</TIME> XML tags and calls Ball_DizzyImmunity. This is a subset of
DispatchCollisionEvents that only handles the E:NODIZZY event.
Implication: The 4 new handlers do NOT call DispatchCollisionEvents as fallthrough.
They only call DispatchCollisionEvents. This means universal events (N:GOAL, N:TARPIT, E:JUMP,
etc.) do NOT fire on Neon Race, Neon Arena, Glass Race, or Glass Arena boards unless
DispatchCollisionEvents is also called. However, since DispatchCollisionEvents is a separate
function that only handles E:NODIZZY, these boards may handle universal events
elsewhere in their update pipeline, or they may simply not support them.
Wait — correction: Looking more carefully at the decompilation, the function name
The function was previously mislabeled "DispatchCollisionEvents" in Ghidra. It is actually DispatchCollisionEvents
(0x40C5D0) — the decompiler resolved the call target to a thunk/mislabel. The function
at the call target IS DispatchCollisionEvents. So all 4 handlers DO fall through to
DispatchCollisionEvents as expected.
Verification Methodology
- Decompiled all 30 board constructors (15 race + 15 arena) to extract vtable addresses
- Read board name strings ("Board (X)" / "ArenaBoard (X)" + "X RACE" / "X ARENA") to verify race vs arena
- Read vtable[0x1D] (offset +0x74) for all 30 vtables via GhidraMCP read_memory
- Created functions for 4 unknown addresses via create_function API
- Decompiled all 4 new handlers via GhidraMCP
- Renamed all 5 functions (4 new + 1 corrected) via rename_function_by_address
- Verified event names via __strnicmp calls in decompiled code
- Cross-referenced with MESHWORLD event string extraction
All data verified June 2026 from Hamsterball.exe V3.6.c (md5=7d25019366b8d7f55906325bd630d7fe).
🔗 Related Documents
Collision Hook
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/tools/collision_hook/README.md)
Hamsterball Collision Hook
What This Does
Hooks three collision dispatch functions in Hamsterball.exe to log all collision events:
- DispatchCollisionEvents (0x0040C5D0) — shared base handler, processes ALL event types
- TowerCollisionEvents (0x0040DCD0) — Tower board events (catapults, trapdoors, maces)
- ExpertCollisionEvents (0x0040E6A0) — Expert board events (hammers, saws, judges, bells)
Files
| File | Description |
|---|---|
<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>.dll |
The hook DLL — inject into Hamsterball.exe |
injector.exe |
Standalone DLL injector (finds Hamsterball.exe automatically) |
<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>.cfg |
Config file — enable/disable hooks, set filters |
collision_log.csv |
Output log (created automatically when game runs) |
Quick Start
- Copy all files into your Hamsterball game directory (next to
Hamsterball.exe) - Launch
Hamsterball.exenormally - Run
injector.exe(double-click or command line) - Play the game — collision events are logged to
collision_log.csv - Check the CSV in any spreadsheet editor or text editor
Config File (collision_hook.cfg)
# Enable/disable individual hooks (1=on, 0=off)
hook_DispatchCollisionEvents=1
hook_Level=1
hook_Arena=1
# Log options
log_event_names=1
log_ball_pos=1
log_timestamps=1
# Filter: only log events matching this substring (empty = log all)
# Examples: filter=E:JUMP or filter=N:GOAL or filter= (empty = all)
filter=
CSV Output Format
timestamp_ms,handler,scene_ptr,ball_ptr,collobj_ptr,event_name,x,y,z
timestamp_ms— milliseconds since Windows boot (GetTickCount)handler— which function was called (DispatchCollisionEvents/TowerCollisionEvents/ExpertCollisionEvents)scene_ptr— pointer to the Scene/BoardLevel object (ECX/this)ball_ptr— pointer to the [[39040799821588|ball object]]collobj_ptr— pointer to the collision pair arrayevent_name— the event string from the collision object (e.g. "E:JUMP", "N:GOAL")x,y,z— ball position at time of collision
How It Works
The DLL installs inline hooks (jmp detours) at the entry point of each target
function. When the game calls a collision handler, our hook intercepts the call,
logs the event details, then forwards to the original function via a trampoline.
Hamsterball.exe calls DispatchCollisionEvents(this, ball, collObj)
→ jmp to hook_DispatchCollisionEvents
→ log event to CSV
→ call original via trampoline
→ execute relocated original bytes
→ jmp back to DispatchCollisionEvents+5
Technical Details
- Target addresses (image base 0x400000):
- DispatchCollisionEvents: VA 0x0040C5D0
- TowerCollisionEvents: VA 0x0040DCD0
- ExpertCollisionEvents: VA 0x0040E6A0
- Calling convention:
__thiscall(ECX = this/Scene pointer, stack = ball, collObj) - ASLR: The DLL computes the actual address by adding the module base offset
- Trampoline: 5-byte original prologue + jmp back to original+5
Safety
- Hooks are read-only — they only log events, never modify game behavior
- Hooks are cleanly removed on DLL unload (DLL_PROCESS_DETACH)
- All pointer reads use SEH (__try/__except) to prevent crashes from bad pointers
- The injector uses standard CreateRemoteThread + LoadLibrary technique
Building from Source
# DLL
i686-w64-mingw32-gcc -shared -o collision_hook.dll collision_hook.c \
-Wl,--enable-stdcall-fixup -Wl,--out-implib,collision_hook.lib
# Injector
i686-w64-mingw32-gcc -o injector.exe injector.c
Limitations
- Does not modify game behavior (logging only). For actual modding, extend
my_DispatchCollisionEvents_handleretc. to modify parameters or return values. - Inline hook assumes first 5 bytes of target function are complete instructions
(no instruction boundary split at byte 5). Verified for Hamsterball.exe. - Requires Windows (32-bit process). Won't work under Wine (needs SEH + inline hooks).
🔗 Related Documents
Collision System
Hamsterball Collision System
Overview
The collision system uses a spatial tree (octree) for broad-phase culling and
AABB-triangle intersection tests for narrow-phase detection. It is the core of
ball physics — every frame, the ball's position is advanced and tested against
mesh triangles via Ball_AdvancePositionOrCollision.
Architecture
Ball_Update
└─ Ball_CollisionCheck (0x402DE0) — per-frame entry
└─ Mesh_FindClosestCollision (0x465D90) — find closest hit point
├─ CollisionMesh_ctor — build collision mesh
├─ SpatialTree_ctor — build octree
├─ CollisionMesh_AddTriangle — populate tree with triangles
└─ Ball_AdvancePositionOrCollision (0x4564C0) — advance + collide
├─ Collision_TraverseSpatialTree (0x465EF0) — tree walk
│ └─ AABB_ContainsPoint (0x4580D0) — point-in-box test
└─ vtable[0x1C] callback — gravity-aware collision response
CollisionMesh (0xCB4 bytes, vtable 0x4D8E10)
Constructor (0x456D80)
CollisionMesh_ctor(this, param_1) {
this->vtable = &Mesh_DeletingDtor; // +0x00
this->ref_count = param_1; // +0x10
AthenaList_Init(&this->triangles, 0); // +0x18
AthenaList_Init(&this->materials, 0); // +0x430
AthenaList_Init(&this->vertices, 0); // +0x848
this->accumulated_distance = 0; // +0xC74
this->position = {0,0,0}; // +0xCA4/CA8/CAC
Ball_InitBattleMode(this); // +0x00 (vtable call)
}
Key Offsets
| Offset | Type | Description |
|---|---|---|
| +0x00 | void** | Vtable (0x4D8E10) |
| +0x10 | int | Reference count / parent |
| +0x14 | byte | Has trail data flag |
| +0x18 | AthenaList | Triangle list |
| +0x1C | int | Triangle count |
| +0x430 | AthenaList | Material list |
| +0x424 | void** | Triangle data array |
| +0x848 | AthenaList | Vertex normal list |
| +0xC54 | void** | Vertex data array |
| +0xC64 | float | Scale factor (friction) |
| +0xC68 | float | Secondary scale |
| +0xC6C | float | Gravity callback scale |
| +0xC70 | float | Max distance threshold |
| +0xC74 | float | Accumulated distance (reset on collision) |
| +0xC7C | byte | Use gravity callback flag |
| +0xC8C | float | Gravity direction X |
| +0xC90 | float | Gravity direction Y |
| +0xC94 | float | Gravity direction Z |
| +0xCA4 | Vec3 | Position (X, Y, Z) — accumulated from velocity |
SpatialTree (Octree, vtable 0x4D9038)
Constructor (0x463330)
SpatialTree_ctor(this, mesh_list) {
CollisionNode_BaseInit(this, mesh_list);
this->vtable = &SpatialTree_DeletingDtor; // +0x00
this->scale = 0.1f; // +0x0C (3DCCCCCDh)
this->max_depth = 6; // +0x10
this->min_extent = 0.9f; // +0x14 (3F666666h)
this->flags = 0; // +0x18/19
this->enable_x = 1; // +0x1A
this->enable_y = 1; // +0x1B
this->enable_z = 1; // +0x1C
this->enable_sub1 = 1; // +0x1D
this->enable_sub2 = 1; // +0x1E
}
Key Parameters
| Offset | Value | Description |
|---|---|---|
| +0x0C | 0.1f | Octree node scale |
| +0x10 | 6 | Maximum tree depth |
| +0x14 | 0.9f | Minimum node extent |
Spatial Tree Traversal (0x465EF0)
Collision_TraverseSpatialTree(this, point, result_list) does a 2-level traversal:
-
Level 1: Recurse through child nodes of the octree
- Iterate
this->children[0..count-1]atthis+0x424 - Uses
AthenaList_NextIndex(this+0x18)for iteration tracking
- Iterate
-
Level 2: For each leaf, iterate mesh buffers
- Access
this->mesh_dataatthis+0x08 - Iterate mesh buffer entries at
mesh_data+0x438
- Access
-
Per-triangle test: For each triangle vertex group (3 vertices × 8 floats)
- Call
AABB_ContainsPoint(point, vx, vy, vz)per vertex - If contained,
AthenaList_Append(result_list, vertex_ptr) - Groups processed 4 at a time (iVar5 = 3, counting down)
- Call
This means: walk octree → for each leaf → for each mesh buffer → for each
vertex group → test 3 vertices against AABB → collect matching triangles.
AABB_ContainsPoint (0x4580D0)
Tests if point (x, y, z) is inside axis-aligned bounding box. AABB layout:
+0x00: float min_x
+0x04: float min_y
+0x08: float min_z
+0x0C: float max_x
+0x10: float max_y
+0x14: float max_z
Returns:
- Low byte = 1 if point is inside all 6 planes
- Bit 8 = set if a test failed (point < min for any axis)
- Bit 10 = set if NaN detected
- Bit 14 = set if point == boundary (exact edge hit)
Collision_InitDefaultAABB (0x458000)
Creates a massive bounding box for initial broad-phase test:
// 0x4B18967F ≈ 10,000,000.0f
// 0xCB18967F ≈ -10,000,000.0f
AABB = { min_x=1e7, min_y=1e7, min_z=1e7,
max_x=-1e7, max_y=-1e7, max_z=-1e7 }
AABB_TriangleIntersect2 (0x4583F0)
Double test — calls AABB_TriangleTest6Edges twice. The second call may test
with modified parameters (separating axis theorem — test both AABB edges and
triangle edges as potential separating axes).
Ball_AdvancePositionOrCollision (0x4564C0) — Core Physics
Signature: Ball_AdvancePositionOrCollision(this, out_pos, start_pos, direction, gravity_accel, dt)
Algorithm
- Clear trail data: If
this+0x14 != 0, free all entries inthis+0x18list - Clear vertex normals: Free all entries in
this+0x848list - Apply direction to position: If direction != (0,0,0):
- Compute current and new position magnitude
- If new distance > max_distance (
this+0xC70): normalize and clamp
- Apply friction:
position *= (1.0 - dt) + (1.0 - this+0xC68) * dt - Collision callback or direct offset:
- If
this+0xC7C == 0:out = position + start_pos(simple translation) - If
this+0xC7C != 0: callvtable[0x1C](out, start, dir, accel, dt, &hit)— gravity-aware
- If
- Apply gravity: accumulate gravity vector scaled by dt × gravity_scale
- Update distance counter:
- On no hit:
this+0xC74 += dtand optionally log trail entry - On hit:
this+0xC74 = 0(collision resets accumulated distance)
- On no hit:
- Write output position
Key Physics Constants
| Address | Value | Description |
|---|---|---|
| 0x4CF310 | 1.0 | Identity friction base |
| 0x4CF368 | 0.0 | Zero comparison threshold |
| 0x4CF3F0 | (varies) | Gravity scaling factor |
Mesh_FindClosestCollision (0x465D90)
High-level collision query. Creates a temporary collision mesh, builds a
spatial tree, then traces a ray from start position in a direction to find
the closest triangle intersection.
1. AthenaList_Init(mesh_list, 0)
2. AthenaList_Append(mesh_list, this) // add self
3. CollisionMesh_ctor(collision_mesh, 0)
4. SpatialTree_ctor(spatial_tree, mesh_list)
5. CollisionMesh_AddTriangle(collision_mesh, spatial_tree)
6. Vec3_NormalizeAndScale(direction, 99999.0) // extend ray very far
7. Ball_AdvancePositionOrCollision(collision_mesh, out, start, dir, gravity, 0.01)
8. SpatialTree_Free(spatial_tree)
9. Mesh_Clear(collision_mesh)
10. Vec3List_Free(mesh_list)
11. return out_position
The time step is hardcoded to 0.01 and the ray extends to 99999.0 units,
making this effectively a raycast from start→direction finding the nearest
triangle surface.
Collision_GradientEval_Stub (0x458190)
Empty stub — return;. Called from Ball_Update and Gear_AdvanceAlongPath
as a no-op placeholder for gradient evaluation (unused feature).
Integration with Ball Physics
The collision system is called from Ball_Update (0x405190) every frame:
- Ball computes desired new position from velocity + forces
Ball_CollisionChecktests the mesh viaMesh_FindClosestCollisionBall_AdvancePositionOrCollisionupdates position, detects collision- On collision: velocity zeroed, accumulated distance reset, material tracked
- On no collision: advance freely, accumulate trail data
Collision Event Dispatch System
The collision system dispatches game events when the ball hits specific object types.
There are two dispatchers: TowerCollisionEvents (for level objects) and ExpertCollisionEvents (for ArenaBoard arena objects). Both are vtable-driven — the scene's vtable determines which handler runs. Both delegates to DispatchCollisionEvents (0x40C5D0) as a shared base handler for universal events (E:JUMP, N:GOAL, E:BREAK, etc.).
Note: Ball_AdvancePositionOrCollision (0x4564C0) handles only geometric collision detection (velocity integration, mesh intersection via CollisionLevel->vtable[0x1C], max-speed clamping). It does NOT dispatch event-name-based collision events. The event dispatch is triggered separately from the ball update chain (ball->vtable[0x10], called by Scene_UpdateBallsAndState).
TowerCollisionEvents (0x40DCD0) — Level Objects
String-based dispatch on collider type name (collider+0x864):
| Type Name | Behavior |
|---|---|
| "E:CATAPULTBOTTOM" | Catapult_Launch if ball cooldown < 1, play catapult sound |
| "E:OPENSESAME" | Trapdoor_Open on first door in list |
| "N:TRAPDOOR" | Trapdoor_Activate on doors matching collider ID |
| "E:BITE" | Set damage=25.0 (damage_amount+0x43A0, damage_timer+0x43A8) |
| "E:MACETRIGGER" | Set active=1 for all maces in mace list |
| "N:MACE" | Ball bounce callback (vtable[0x20]) if mace is moving |
ExpertCollisionEvents (0x40E6A0) — ArenaBoard Arena Objects
| Type Name | Behavior |
|---|---|
| "E:CALLHAMMER" | CreateBonkPopup (tournament only) |
| "E:HAMMERCHASE" | Hammer_ChaseStart (tournament only) |
| "E:ALERTSAW1"/"ALERTSAW2" | Saw_AlertActivate (warning phase) |
| "E:ACTIVATESAW1"/"ACTIVATESAW2" | Saw_Activate (full activation) |
| "E:ALERTJUDGES" | Judge_Reset for all judges |
| "E:SCORE<time>" | ScoreDisplay_SetTime parsed from suffix |
| "E:JUMP" | Jump pad: cooldown=10, vert_vel=0.008, vert_vel_on=1, play sound |
| "E:BELL<suffix>" | Bell_Activate, add 500 bonus time if not playing |
Level_LoadCollision (0x65260) — Collision File Format
Binary .COL file format loaded at level startup:
Header: 24 bytes into MeshWorld+0x45C (transform/flags)
int32: sublevel_count
If sublevel_count < 1 (single-mesh mode):
int32: object_count
For each object:
CreateMeshBuffer(0x874 bytes)
int32: name_length
char[name_length]: name string ("N:"=interactive, "E:"=event)
int32: face_count
For each face (0x60 bytes each):
9 floats: v0, v1, v2 positions (3 floats each)
Compute normal via cross product, normalize via SIMD
Same normal stored for all 3 verts (flat shading)
If sublevel_count >= 1 (multi-level mode):
scene->is_sprite_mode = 1
For each sublevel: Level_ctor + vtable[0x60](file) recursive load
CollisionFace Structure (0x60 bytes)
| Offset | Field | Description |
|---|---|---|
| +0x00 | v0.x | Vertex 0 X |
| +0x04 | v0.y | Vertex 0 Y |
| +0x08 | v0.z | Vertex 0 Z |
| +0x0C | n0.x | Face normal X (flat, same for all verts) |
| +0x10 | n0.y | Face normal Y |
| +0x14 | n0.z | Face normal Z |
| +0x18 | v1.x | Vertex 1 X |
| +0x1C | v1.y | Vertex 1 Y |
| +0x20 | v1.z | Vertex 1 Z |
| +0x24 | n1.x | Face normal X (same as n0) |
| +0x28 | n1.y | Face normal Y |
| +0x2C | n1.z | Face normal Z |
| +0x30 | v2.x | Vertex 2 X |
| +0x34 | v2.y | Vertex 2 Y |
| +0x38 | v2.z | Vertex 2 Z |
| +0x3C | n2.x | Face normal X (same as n0) |
| +0x40 | n2.y | Face normal Y |
| +0x44 | n2.z | Face normal Z |
MeshBuffer Structure (0x874 bytes)
| Offset | Field | Description |
|---|---|---|
| +0x000 | vtable | CollisionMesh vtable |
| +0x00C | AthenaList | Face list |
| +0x217 | byte | render_flag (=0) |
| +0x85D | byte | interactive_flag (1 for N:/E: prefixes) |
| +0x863 | byte | no_render_flag (1 for E: prefix only) |
| +0x864 | char* | Type name string ("N:WALL", "E:CATAPULTBOTTOM", etc.) |
- Vtable[0x1C] callback handles gravity-plane-aware collision (tilted/flat modes)
🔗 Related Documents
collision_hook
collision_hook
Hooks collision detection for debugging
Files
collision_hook.c— C source codecollision_hook.dll— Compiled DLL (PE32 i386)
Proxy Type
Standalone DLL (not a bass proxy). Use an DLL injector or launcher tool.
🔗 Related Documents
CollisionMesh (Physics Object)
CollisionMesh (Physics Object) — Complete Modder's Reference
Parameter of
Ball_Update@0x00405E00(accessed viaBall + 0x1A4).
All offsets below verified via live Ghidra decompilation ofHamsterball.exe.
Confidence markers: ✅ = Verified in raw decompiled C (2+ functions), ⚠️ = Verified in 1 function, ❓ = Inferred from struct layout.
What Is the CollisionMesh?
The CollisionMesh is NOT the Ball itself. It is a separate nested physics object allocated during Ball_ctor2 and pointed to by the Ball at offset +0x1A4:
Ball + 0x1A4 → CollisionMesh* (heap object, 0xCB0 bytes)
Ball_Update (0x405E00) is the function that reads this pointer and integrates all physics state. If you want to modify how the ball moves in the world — speed, gravity, friction, jumping — this is the object you modify, not the ephemeral Ball + 0x170 velocity accumulator (which gets zeroed every frame).
Getting the Pointer
// Method 1: From any Ball pointer
int* ball = (int*)0x...; // your Ball* hook parameter
int* phys = *(int**)((char*)ball + 0x1A4);
// Method 2: From the Ball_Update hook (0x405E00)
// param_1 IS the Ball*, so:
int* phys = *(int**)((char*)param_1 + 0x1A4);
Full CollisionMesh Struct Layout
The collisionmesh object is 0xCB0 bytes (3248). It inherits from SceneObject (base vtable + refcount), then adds physics fields.
| Offset | Type | Field | Default | Verified By | Modding Use |
|---|---|---|---|---|---|
+0x000 |
void** |
vtable |
0x4D8E10 |
✅ CollisionMesh_ctor (0x405680) |
Call virtual methods |
+0x004 |
int |
ref_count |
1 |
✅ CollisionMesh_ctor |
Reference counting |
+0x010 |
Ball* |
owner_ball |
this (Ball*) |
✅ CollisionMesh_ctor |
Back-pointer to parent |
+0x014 |
— | (SceneObject base) |
— | ✅ Inheritance | — |
+0xC60 |
int |
battle_mode |
3 |
✅ Ball_InitBattleMode |
Arena mode flag |
+0xC64 |
float |
roll_friction |
computed | ✅ Ball_Update Phase 14 |
⚠️ OVERWRITTEN EVERY FRAME |
+0xC68 |
float |
friction_damping |
~0.56 |
✅ Ball_InitBattleMode |
Ground friction multiplier |
+0xC6C |
float |
field_c6c |
1.0f |
✅ Ball_InitBattleMode |
Unknown scaler |
+0xC70 |
float |
max_speed_limit |
1000.0f |
✅ Ball_InitBattleMode |
Hard speed cap |
+0xC74 |
int |
field_c74 |
0 |
✅ CollisionMesh_ctor |
Unused? |
+0xC78 |
float |
gravity_strength |
25.0f |
✅ Ball_InitBattleMode |
Gravity pull strength |
+0xC7C |
byte |
use_gravity |
1 |
✅ Ball_InitBattleMode |
0 = no gravity |
+0xC80 |
— | (padding) |
— | ❓ Alignment | — |
+0xC8C |
float |
dir_x |
0 |
✅ Ball_InitBattleMode |
Physics up-vector X |
+0xC90 |
float |
dir_y |
-1.0f |
✅ Ball_InitBattleMode |
Physics up-vector Y |
+0xC94 |
float |
dir_z |
0 |
✅ Ball_InitBattleMode |
Physics up-vector Z |
+0xC98 |
float |
scaled_dir_x |
— | ✅ CollisionMesh_SetSpeed |
NEVER READ by engine |
+0xC9C |
float |
scaled_dir_y |
— | ✅ CollisionMesh_SetSpeed |
NEVER READ by engine |
+0xCA0 |
float |
scaled_dir_z |
— | ✅ CollisionMesh_SetSpeed |
NEVER READ by engine |
+0xCA4 |
float |
vel_x |
0 |
✅ Ball_AdvancePositionOrCollision |
Persistent physics X velocity |
+0xCA8 |
float |
vel_y |
0 |
✅ Ball_AdvancePositionOrCollision |
Persistent physics Y velocity — JUMP MOD TARGET |
+0xCAC |
float |
vel_z |
0 |
✅ Ball_AdvancePositionOrCollision |
Persistent physics Z velocity |
Total size: 0xCB0 bytes (3248).
Constructor: CollisionMesh_ctor @ 0x405680 allocates via operator_new(0xCB0).
Vtable: 0x4D8E10 (inherits SceneObject methods + physics overrides).
Two Velocity Systems (CRITICAL)
There are two separate velocity systems in Hamsterball. Writing to the wrong one has no lasting effect.
| System | Address | Type | Persistence | Who Clears It |
|---|---|---|---|---|
| Ball input velocity | Ball + 0x170/174/178 |
float[3] |
Ephemeral — cleared every frame | Ball_Update Phase 1 zeros these after reading |
| CollisionMesh physics velocity | CollisionMesh + 0xCA4/CA8/CAC |
float[3] |
Persistent — survives frames | Integrated by Ball_AdvancePositionOrCollision (0x4564C0) |
Modding rule: If you write to Ball + 0x174 (vel_y), it gets overwritten to 0 within one frame. If you write to *(Ball+0x1A4) + 0xCA8 (CollisionMesh.vel_y), the physics integrator uses it on the next tick and the ball actually moves.
Verified Functions
CollisionMesh Lifecycle
| Address | Name | Role | Verified |
|---|---|---|---|
0x405680 |
CollisionMesh_ctor |
Allocates 0xCB0 bytes, stores Ball* at +0x10, sets vtable to 0x4D8E10 |
✅ |
0x4056D0 |
Ball_InitBattleMode |
Sets default physics params (gravity=25.0f, speed limit=1000.0f) | ✅ |
0x4030B0 |
Ball_ResetCollisionMesh |
Resets direction to -1.0f Y, clears impact state |
✅ |
Physics Integration
| Address | Name | Role | Verified |
|---|---|---|---|
0x405E00 |
Ball_Update |
Main physics tick — reads CollisionMesh, integrates, writes back. Phase 14 overwrites roll_friction every frame. |
✅ |
0x4564C0 |
Ball_AdvancePositionOrCollision |
Reads vel_x/y/z (+0xCA4/CA8/CAC), applies gravity, does collision broad phase, writes updated position back to Ball + 0x164/168/16C |
✅ |
0x402650 |
Ball_ApplyForce |
Accumulates force into Ball's ephemeral velocity (+0x170), NOT CollisionMesh | ✅ |
Direct Field Access
| Address | Name | Role | Verified |
|---|---|---|---|
0x402A20 |
Ball_SetVec3AtOffset |
Direct overwrite of CollisionMesh + 0xCA4/CA8/CAC (velocity) |
✅ |
0x4029C0 |
CollisionMesh_SetSpeed |
⚠️ DEAD CODE — writes +0xC64 (roll_friction, overwritten next frame) and +0xC98/C9C/CA0 (never read). No lasting effect. |
✅ |
Modding Recipes
Recipe 1: The Jump Mod (Verified Working)
This is how the community jump mod works. It injects upward velocity directly into the CollisionMesh, bypassing the ephemeral Ball velocity:
void InjectJump(void* ball, float jump_power) {
// Get the CollisionMesh pointer from the Ball
int* phys = *(int**)((char*)ball + 0x1A4);
if (!phys) return;
// Write directly to persistent physics velocity
*(float*)((char*)phys + 0xCA8) = jump_power; // vel_y = upward impulse
// Optional: also zero X/Z velocity for clean vertical jump
// *(float*)((char*)phys + 0xCA4) = 0.0f;
// *(float*)((char*)phys + 0xCAC) = 0.0f;
}
Why this works: Ball_AdvancePositionOrCollision reads +0xCA8 on the next tick, adds gravity (gravity_strength * dt), runs collision against the level mesh, and updates Ball->pos accordingly. The velocity persists until the ball hits a surface or Ball_ResetCollisionMesh is called.
Recipe 2: Super Speed (Modify Speed Limit)
void SetSuperSpeed(void* ball, float new_limit) {
int* phys = *(int**)((char*)ball + 0x1A4);
if (!phys) return;
// Raise the hard speed cap
*(float*)((char*)phys + 0xC70) = new_limit; // max_speed_limit
}
Note: The engine clamps speed every frame. Raising max_speed_limit allows higher speeds without the clamp kicking in. You still need to inject velocity (Recipe 1 or ApplyForce) to actually go fast.
Recipe 3: Disable Gravity
void DisableGravity(void* ball) {
int* phys = *(int**)((char*)ball + 0x1A4);
if (!phys) return;
*(char*)((char*)phys + 0xC7C) = 0; // use_gravity = false
}
Effect: Ball_AdvancePositionOrCollision skips gravity application. The ball stops falling and maintains whatever velocity you injected. Combine with a small constant upward velocity for hover/fly mode.
Recipe 4: Low Friction / Ice Mode
void SetLowFriction(void* ball, float friction) {
int* phys = *(int**)((char*)ball + 0x1A4);
if (!phys) return;
*(float*)((char*)phys + 0xC68) = friction; // friction_damping
}
Default: ~0.56. Set to 0.01 for ice physics (ball slides forever). Set to 2.0 for sticky mud.
⚠️ WARNING: Ball_Update Phase 14 also computes and writes roll_friction (+0xC64) every single frame:
roll_friction = (radius * 0.98 * friction_damping * speed_scale) / 0.96
So modifying +0xC64 directly is futile — it gets overwritten. Always modify friction_damping (+0xC68) instead.
Recipe 5: Reverse Gravity
void ReverseGravity(void* ball) {
int* phys = *(int**)((char*)ball + 0x1A4);
if (!phys) return;
*(float*)((char*)phys + 0xC90) = 1.0f; // dir_y = +1.0 (up is down)
}
Effect: The physics integrator uses dir as the "up" vector for gravity application. -1.0f = normal gravity (pulls toward -Y). +1.0f = reverse gravity (pulls toward +Y). You can set any normalized vector for weird directional gravity.
Recipe 6: Instant Stop
void InstantStop(void* ball) {
int* phys = *(int**)((char*)ball + 0x1A4);
if (!phys) return;
*(float*)((char*)phys + 0xCA4) = 0.0f; // vel_x = 0
*(float*)((char*)phys + 0xCA8) = 0.0f; // vel_y = 0
*(float*)((char*)phys + 0xCAC) = 0.0f; // vel_z = 0
}
Effect: Zeroes all persistent physics velocity. The ball stops moving instantly (no inertia). Useful for teleportation or checkpoint resets.
⚠️ DEAD CODE WARNING: CollisionMesh_SetSpeed
Address: 0x4029C0
What it does:
void CollisionMesh_SetSpeed(void* this, float speed) {
*(float*)((char*)this + 0xC64) = speed; // roll_friction
*(float*)((char*)this + 0xC98) = speed * dir_x; // scaled_dir_x
*(float*)((char*)this + 0xC9C) = speed * dir_y; // scaled_dir_y
*(float*)((char*)this + 0xCA0) = speed * dir_z; // scaled_dir_z
}
Why it does nothing:
+0xC64(roll_friction) is recomputed and overwritten every frame byBall_UpdatePhase 14.+0xC98/C9C/CA0(scaled_dir) are never read by any engine function. They are sinkholes.
Historical naming note: This was originally called Ball_SetSpeed in early RE because it was found through Ball cross-references. The decompilation reveals it accesses CollisionMesh offsets on this, proving it's a CollisionMesh method, not a Ball method. Do not call this function expecting speed changes.
Ball_Update Execution Order (0x405E00)
Understanding the tick order helps you know WHEN to inject modifications:
Phase 1: Decay timers (ice effect, lerp factors)
Phase 2: Read ephemeral Ball velocity (+0x170) into locals, then ZERO it
Phase 3: Read input force from keyboard/joystick
Phase 4: Apply force multipliers (tilted gravity, speed scale)
Phase 5: Build spatial collision tree
Phase 6: Apply gravity to CollisionMesh velocity (+0xCA8)
Phase 7: Integrate position from CollisionMesh velocity
Phase 8: Collision detection (broad phase → narrow phase)
Phase 9: Collision response (bounce, friction, normal reflection)
Phase 10: Write updated position back to Ball->pos (+0x164)
Phase 11: Write updated CollisionMesh velocity back (+0xCA4/CA8/CAC)
Phase 12: Trail particle spawn
Phase 13: Audio state update
Phase 14: Compute roll_friction = (radius * 0.98 * friction_damping * speed_scale) / 0.96
→ WRITE to CollisionMesh + 0xC64 (overwrites any external value)
Phase 15: Check out-of-bounds, respawn if needed
Injection timing:
- Before Phase 6: Your velocity gets gravity applied → good for jump
- After Phase 11: Your velocity overwrites the integrated result → good for instant stop
- Phase 14:
roll_frictionis always overwritten — never write to+0xC64directly
Cross-Reference: Ball ↔ CollisionMesh
| What you want | Ball offset | CollisionMesh offset |
|---|---|---|
| Position (read/write) | +0x164/168/16C |
N/A (position is in Ball) |
| Ephemeral input velocity | +0x170/174/178 |
N/A (zeroed every frame) |
| Persistent physics velocity | +0x1A4 → ptr → +0xCA4/CA8/CAC |
+0xCA4/CA8/CAC |
| Speed limit | +0x1A4 → ptr → +0xC70 |
+0xC70 |
| Gravity toggle | +0x1A4 → ptr → +0xC7C |
+0xC7C |
| Gravity strength | +0x1A4 → ptr → +0xC78 |
+0xC78 |
| Friction damping | +0x1A4 → ptr → +0xC68 |
+0xC68 |
| Physics up-vector | +0x1A4 → ptr → +0xC8C/C90/C94 |
+0xC8C/C90/C94 |
| Roll friction (computed) | +0x1A4 → ptr → +0xC64 |
+0xC64 |
Sources
All data verified via live GhidraMCP headless decompilation on Hamsterball.exe:
Ball_Update@0x405E00— main physics tick (raw C, 15 phases)Ball_AdvancePositionOrCollision@0x4564C0— position integration + collisionCollisionMesh_ctor@0x405680— constructor, field initializationBall_InitBattleMode@0x4056D0— default physics paramsBall_ApplyForce@0x402650— ephemeral force accumulatorBall_SetVec3AtOffset@0x402A20— direct velocity overwriteCollisionMesh_SetSpeed@0x4029C0— dead code analysisBALL_OBJECT_MODDING.md— parent Ball struct for cross-reference
Document generated: 2026-06-06
Method: Ghidra decompilation + cross-reference with existing ball docs + dead code analysis
Confidence: High for ✅ verified offsets, Medium for ⚠️ single-source offsets
🔗 Related Documents
CollisionMesh Object
CollisionMesh Object — Complete Modder's Reference
Analysis of the
playerObject + 0x1A4pointer chain used by the jump mod and other physics hacks.
All offsets verified via live Ghidra MCP decompilation ofHamsterball.exe.
This document describes theCollisionMeshobject — a nested physics body that lives inside the Ball.
Quick Stats
| Property | Value |
|---|---|
| Primary update function | Ball_AdvancePositionOrCollision @ 0x00405640 |
| Constructor | CollisionMesh_ctor @ 0x00405680 |
| Total struct size | 0x0CB0 bytes (3,248 bytes) |
| Parent backref | Ball* stored at +0x0010 |
| VTable | 0x004D8E10 (Mesh_DeletingDtor) |
| Pointer in Ball | Ball + 0x1A4 stores CollisionMesh* |
The Pointer Chain Explained
// The jump mod follows this exact chain:
// 1. Ball + 0x1A4 → CollisionMesh* (pointer field inside Ball)
// 2. *(Ball + 0x1A4) + 0xCA8 → physics velocity Y (float)
DWORD* physicsObjPtr = (DWORD*)((DWORD)playerObject + 0x1a4); // CollisionMesh**
DWORD physicsObj = *physicsObjPtr; // CollisionMesh*
float* trueVelY = (float*)(physicsObj + 0xca8); // &collisionMesh->vel_y
The Ball struct ends at ~0xC98. The CollisionMesh object it points to extends beyond the Ball's own size with its own fields up to +0xCA8 and beyond. These are NOT Ball fields — they belong to the nested object.
How CollisionMesh is Created
From Ball_ctor2 (0x004039E0) decompilation:
pvVar2 = operator_new(0xcb0); // Allocate 3,248 bytes
pvVar2 = CollisionMesh_ctor(pvVar2, this); // Initialize, pass Ball as owner
*(void **)((int)this + 0x1a4) = pvVar2; // Store pointer in Ball struct
CollisionMesh is a completely separate object with its own vtable (0x4D8E10), its own constructor (0x405680), and its own destructor. It lives on the heap and is pointed to by Ball + 0x1A4.
Why Modders Target CollisionMesh Instead of Ball Velocity
System A: Ball Input Velocity (NOT the target)
| Field | Address | Type | Behaviour |
|---|---|---|---|
vel_x |
Ball + 0x0170 |
float |
Input force accumulator |
vel_y |
Ball + 0x0174 |
float |
Input force accumulator |
vel_z |
Ball + 0x0178 |
float |
Input force accumulator |
- Written by
Ball_ApplyForceWithMultipliers(0x402650) - Read by
Ball_Update(0x405E00) then cleared to zero every frame - Writing here does nothing — values are erased before physics integration
System B: CollisionMesh Physics Velocity (THE target)
| Field | Address | Type | Behaviour |
|---|---|---|---|
vel_x |
CollisionMesh + 0x0CA4 |
float |
Persistent physics state |
vel_y |
CollisionMesh + 0x0CA8 |
float |
Persistent physics state |
vel_z |
CollisionMesh + 0x0CAC |
float |
Persistent physics state |
- Read/written by
Ball_UpdateandBall_AdvancePositionOrCollision - Survives across frames — integrated by the physics engine
- The jump mod writes
20.0fto+0xCA8to inject an upward impulse
Complete CollisionMesh Field Map
Identity / Header
| Offset | Hex | Type | Name | Initial | Notes |
|---|---|---|---|---|---|
+0x0000 |
0x0000 |
uint32_t |
vtable |
0x4D8E10 |
Mesh_DeletingDtor |
+0x0004 |
0x0004 |
uint32_t |
field_04 |
0 |
— |
+0x0008 |
0x0008 |
uint32_t |
field_08 |
0 |
— |
+0x000C |
0x000C |
uint32_t |
field_0C |
0 |
— |
+0x0010 |
0x0010 |
Ball* |
owner_ball |
this |
Back-pointer to parent Ball |
AthenaList #1 — Collision face list
| Offset | Hex | Type | Name | Notes |
|---|---|---|---|---|
+0x0018 |
0x0018 |
uint32_t |
list1_count |
0 (from AthenaList_Init) |
+0x001C |
0x001C |
uint32_t |
list1_capacity |
0 |
+0x0020 |
0x0020 |
void** |
list1_data |
Array of collision face pointers |
+0x0424 |
0x0424 |
void** |
list1_end_ptr |
End of list data |
- Total span:
0x0018to~0x042F(~1,044 bytes) - Populated during collision detection with level geometry
AthenaList #2 — Secondary collision data
| Offset | Hex | Type | Name | Notes |
|---|---|---|---|---|
+0x0430 |
0x0430 |
uint32_t |
list2_count |
0 |
+0x0434 |
0x0434 |
uint32_t |
list2_capacity |
0 |
+0x0438 |
0x0438 |
void** |
list2_data |
— |
+0x083C |
0x083C |
void** |
list2_end_ptr |
— |
- Total span:
0x0430to~0x0847(~1,047 bytes)
AthenaList #3 — Tertiary collision data
| Offset | Hex | Type | Name | Notes |
|---|---|---|---|---|
+0x0848 |
0x0848 |
uint32_t |
list3_count |
0 |
+0x084C |
0x084C |
uint32_t |
list3_capacity |
0 |
+0x0850 |
0x0850 |
void** |
list3_data |
— |
+0x0C54 |
0x0C54 |
void** |
list3_end_ptr |
— |
- Total span:
0x0848to~0x0C57(~1,047 bytes) - Freed and re-allocated during
Ball_AdvancePositionOrCollision
Physics Parameters
| Offset | Hex | Type | Name | Initial Value | Source | Modding Use |
|---|---|---|---|---|---|---|
+0x0C60 |
0x0C60 |
int32_t |
battle_mode |
3 |
Ball_InitBattleMode |
Change game mode physics |
+0x0C64 |
0x0C64 |
float |
roll_friction |
~0.56f |
Ball_Update Phase 14 |
|
+0x0C68 |
0x0C68 |
float |
friction_damping |
~0.56f |
Ball_InitBattleMode |
Lower = more slippery |
+0x0C6C |
0x0C6C |
float |
field_c6c |
1.0f |
Ball_InitBattleMode |
Unknown physics param |
+0x0C70 |
0x0C70 |
float |
max_speed_limit |
1000.0f |
Ball_InitBattleMode |
Speed cap (raise for turbo) |
+0x0C74 |
0x0C74 |
uint32_t |
field_c74 |
0 |
CollisionMesh_ctor |
— |
+0x0C78 |
0x0C78 |
float |
gravity_strength |
25.0f |
Ball_InitBattleMode |
Lower = moon gravity |
+0x0C7C |
0x0C7C |
uint8_t |
use_gravity |
1 |
Ball_InitBattleMode |
Set to 0 = zero gravity |
+0x0C7D |
0x0C7D |
uint8_t |
field_c7d |
— | — | Padding/unknown |
+0x0C7E |
0x0C7E |
uint16_t |
field_c7e |
— | — | Padding/unknown |
Direction Vector (Normalized Heading)
| Offset | Hex | Type | Name | Initial Value | Notes |
|---|---|---|---|---|---|
+0x0C80 |
0x0C80 |
float |
field_c80 |
0 |
Unused / unknown |
+0x0C84 |
0x0C84 |
float |
field_c84 |
0 |
Unused / unknown |
+0x0C88 |
0x0C88 |
float |
field_c88 |
0 |
Unused / unknown |
+0x0C8C |
0x0C8C |
float |
dir_x |
0 |
Normalized heading X |
+0x0C90 |
0x0C90 |
float |
dir_y |
-1.0f |
Default = straight down |
+0x0C94 |
0x0C94 |
float |
dir_z |
0 |
Normalized heading Z |
- Direction is reset to
(0, -1, 0)byBall_ResetCollisionMesh(0x4030B0) CollisionMesh_SetSpeedmultipliesspeed_scalarbydirto producescaled_dir
Scaled Direction (UNUSED — Written by CollisionMesh_SetSpeed but NEVER READ)
|| Offset | Hex | Type | Name | Initial Value | Notes |
|--------|-----|------|------|---------------|-------|
| +0x0C98 | 0x0C98 | float | scaled_dir_x | 0 | UNUSED — written by CollisionMesh_SetSpeed (0x4029C0), never read by physics engine |
| +0x0C9C | 0x0C9C | float | scaled_dir_y | 0 | UNUSED — same as above |
| +0x0CA0 | 0x0CA0 | float | scaled_dir_z | 0 | UNUSED — same as above |
- These fields are dead values.
CollisionMesh_SetSpeedwrites them, but nothing in the game loop ever dereferences them. - Writing to
+0xC64(viaCollisionMesh_SetSpeedor direct poke) has no lasting effect —Ball_UpdatePhase 14 computesroll_frictionand overwrites it every frame.
Physics Velocity (THE JUMP MOD TARGET)
| Offset | Hex | Type | Name | Initial Value | Used By |
|---|---|---|---|---|---|
+0x0CA4 |
0x0CA4 |
float |
vel_x |
0 |
Ball_AdvancePositionOrCollision, Ball_Update |
+0x0CA8 |
0x0CA8 |
float |
vel_y |
0 |
← Jump mod writes here |
+0x0CAC |
0x0CAC |
float |
vel_z |
0 |
Ball_AdvancePositionOrCollision, Ball_Update |
- These are the actual physics velocities used for movement integration
- Not cleared each frame — they persist and accumulate gravity/collisions
Ball_Updatereads these, copies to local variables, runs physics, then writes back
Post-Velocity Fields
| Offset | Hex | Type | Name | Notes |
|---|---|---|---|---|
+0x0CB0 |
0x0CB0 |
— | struct_end |
End of CollisionMesh struct |
Address Cheat Sheet
For quick reference when writing mods:
// === Accessing the CollisionMesh from a Ball pointer ===
void* ball = playerObject; // Ball* (player object)
void** cmPtr = (void**)((DWORD)ball + 0x1A4); // CollisionMesh** (pointer field)
void* cm = *cmPtr; // CollisionMesh* (dereferenced)
// === Identity ===
uint32_t* cmVtable = (uint32_t*)((DWORD)cm + 0x0000);
Ball** cmOwner = (Ball**)((DWORD)cm + 0x0010);
// === Physics Parameters ===
float* cmSpeedScalar = (float*)((DWORD)cm + 0x0C64);
float* cmFriction = (float*)((DWORD)cm + 0x0C68);
float* cmMaxSpeed = (float*)((DWORD)cm + 0x0C70);
float* cmGravity = (float*)((DWORD)cm + 0x0C78);
uint8_t* cmUseGravity = (uint8_t*)((DWORD)cm + 0x0C7C);
// === Direction ===
float* cmDirX = (float*)((DWORD)cm + 0x0C8C);
float* cmDirY = (float*)((DWORD)cm + 0x0C90);
float* cmDirZ = (float*)((DWORD)cm + 0x0C94);
// === Scaled Direction ===
float* cmScaledDirX = (float*)((DWORD)cm + 0x0C98);
float* cmScaledDirY = (float*)((DWORD)cm + 0x0C9C);
float* cmScaledDirZ = (float*)((DWORD)cm + 0x0CA0);
// === PHYSICS VELOCITY (persistent) ===
float* cmVelX = (float*)((DWORD)cm + 0x0CA4);
float* cmVelY = (float*)((DWORD)cm + 0x0CA8); // ← Jump mod target
float* cmVelZ = (float*)((DWORD)cm + 0x0CAC);
Key Functions
CollisionMesh Lifecycle
| Function | Address | Description |
|---|---|---|
CollisionMesh_ctor |
0x00405680 |
Allocates 0xCB0 bytes, initializes lists, zeros velocity |
Mesh_DeletingDtor |
0x004D8E10 (vtable) |
Destructor — frees collision data |
Ball Functions That Touch CollisionMesh
| Function | Address | What it does to CollisionMesh |
|---|---|---|
Ball_ctor2 |
0x004039E0 |
Calls operator_new(0xCB0) → CollisionMesh_ctor, stores ptr at +0x1A4 |
Ball_Update |
0x00405E00 |
Reads cm.vel into locals, integrates physics, writes back |
Ball_AdvancePositionOrCollision |
0x00405640 |
Main physics integrator — adds external velocity to cm.vel, applies gravity, collision response, friction |
Ball_InitBattleMode |
0x004056D0 |
Sets cm.gravity_strength, cm.max_speed_limit, cm.friction, cm.battle_mode, resets cm.dir to down |
CollisionMesh_SetSpeed |
0x004029C0 |
Writes cm[0xC64]=speed, cm[0xC98/C9C/CA0]=speed*dir. DEAD CODE — effects immediately overwritten by Ball_Update Phase 14. Does NOT control ball speed in any meaningful way. |
Ball_SetVec3AtOffset |
0x00402A20 |
Directly overwrites cm.vel_x/y/z with a Vec3 |
Ball_ResetCollisionMesh |
0x004030B0 |
Resets cm.dir to (0, -1, 0), clears impact counter |
Ball_FindMeshCollision |
0x00403980 |
Passes this (CollisionMesh*) to Mesh_FindClosestCollision |
The Ground Check Explained
float tolerance = 0.5f;
if (*trueVelY > -tolerance && *trueVelY < tolerance) {
*trueVelY = 20.0f;
}
The mod checks if CollisionMesh.vel_y is between -0.5 and +0.5. This is a grounded check:
- When resting on a surface: Collision response from
Ball_AdvancePositionOrCollisionexactly cancels gravity →vel_y ≈ 0 - When airborne:
vel_yis typically negative (falling due to gravity) or has significant magnitude - The tolerance
0.5faccounts for minor numerical jitter when the ball is "settled"
When the condition is met, the mod injects 20.0f into cm.vel_y, giving the ball an immediate upward velocity that the physics integrator will process over subsequent frames.
Modding Recipes
1. Super Jump
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(float*)((DWORD)cm + 0x0CA8) = 50.0f; // Double the original mod's force
2. Horizontal Launch (dash)
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(float*)((DWORD)cm + 0x0CA4) = 100.0f; // Blast forward in X
*(float*)((DWORD)cm + 0x0CAC) = 0.0f; // No Z velocity
3. Velocity Freeze (stop instantly)
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(float*)((DWORD)cm + 0x0CA4) = 0.0f;
*(float*)((DWORD)cm + 0x0CA8) = 0.0f;
*(float*)((DWORD)cm + 0x0CAC) = 0.0f;
4. Moon Gravity
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(float*)((DWORD)cm + 0x0C78) = 5.0f; // Default is 25.0f
5. Disable Gravity Entirely
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(uint8_t*)((DWORD)cm + 0x0C7C) = 0; // Set use_gravity = false
6. Turbo Speed (raise speed cap)
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(float*)((DWORD)cm + 0x0C70) = 5000.0f; // Default is 1000.0f
7. Slippery Mode (reduce friction)
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(float*)((DWORD)cm + 0x0C68) = 0.1f; // Default is ~0.56
8. Zero Gravity
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(uint8_t*)((DWORD)cm + 0x0C7C) = 0; // Disable gravity
9. Direction Override (face a specific way)
Note: +0xC8C/C90/C94 (dir) is read by Ball_Update and used to compute roll direction. Changing it persists until Ball_ResetCollisionMesh resets it to (0, -1, 0).
void* ball = playerObject;
void* cm = *(void**)((DWORD)ball + 0x1A4);
*(float*)((DWORD)cm + 0x0C8C) = 0.0f; // dir_x = 0
*(float*)((DWORD)cm + 0x0C90) = 1.0f; // dir_y = up
*(float*)((DWORD)cm + 0x0C94) = 0.0f; // dir_z = 0
The Two Velocity Systems Side by Side
| Property | Ball Input Velocity | CollisionMesh Physics Velocity |
|---|---|---|
| Address | Ball + 0x170/174/178 |
CollisionMesh + 0xCA4/CA8/CAC |
| Type | float accumulator |
float persistent state |
| Written by | Ball_ApplyForceWithMultipliers |
Ball_Update, Ball_AdvancePositionOrCollision |
| Read by | Ball_Update (each frame) |
Ball_Update, Ball_AdvancePositionOrCollision |
| Cleared? | YES — set to 0 after reading | NO — persists across frames |
| Use case | Input forces from keys/gamepad | Final movement + gravity + collisions |
| Mod effect | Nothing (erased) | Actual movement change |
Verified via Ghidra MCP
All offsets cross-referenced against live decompiled code:
Ball_ctor2(0x4039E0):operator_new(0xCB0)→CollisionMesh_ctor→ stored atthis + 0x1A4Ball_ApplyForceWithMultipliers(0x402650): Writes toBall + 0x170/174/178Ball_Update(0x405E00): Reads Ball.vel atparam_1[0x5c/5d/5e]then clears to zero. Reads/writesCollisionMesh + 0xCA4/CA8/CACviaparam_1[0x69].Ball_AdvancePositionOrCollision(0x405640): IntegratesCollisionMesh + 0xCA4/CA8/CACwith gravity, friction, collision response.CollisionMesh_ctor(0x405680): Initializes+0xCA4/CA8/CACto0,0,0. Sets+0x10= owner Ball.Ball_InitBattleMode(0x4056D0): Initializes+0xC60through+0xC94.Ball_SetVec3AtOffset(0x402A20): Directly overwrites+0xCA4/CA8/CAC.CollisionMesh_SetSpeed(0x4029C0): DEAD CODE — writes+0xC64and+0xC98/C9C/CA0, butBall_UpdatePhase 14 immediately overwrites+0xC64with computedroll_friction. Nothing ever reads+0xC98/C9C/CA0. Do NOT use for modding.Ball_ResetCollisionMesh(0x4030B0): Resets+0xC8C/C90/C94to(0, -1, 0).
NOTE on
CollisionMesh_SetSpeed: We previously documented this as a "speed setter." Decompilation ofBall_Update(0x405E00) Phase 14 andBall_AdvancePositionOrCollision(0x4564C0) confirm+0xC64isroll_friction, overwritten every frame. Thescaled_dirfields (+0xC98/C9C/CA0) are written but never read by the engine. Calling this function appears to do nothing because its effects are immediately clobbered.
Summary
The jump mod is correct and sophisticated. The author understood the engine's architecture:
Ball + 0x1A4is a pointer to a nestedCollisionMeshobject (size0xCB0)CollisionMesh + 0xCA8is the persistent physics velocity Y, not the ephemeral input accumulator- A near-zero
cm.vel_ymeans the ball is grounded (collision response canceling gravity) - Injecting
20.0fcreates an immediate upward impulse that the physics integrator processes - Writing to
Ball + 0x174would do nothing because that field is cleared every frame
The mod bypasses the normal Ball_ApplyForceWithMultipliers → Ball_Update flow and writes directly to the physics state. This is the correct low-level approach for instant-response cheats.
🔗 Related Documents
Container & Utility Systems
Container & Utility Systems
AthenaList — Dynamic Array / Linked Index Array
AthenaList is the primary container used throughout Hamsterball. It combines a
dynamic array with a 256-entry free index system for O(1) iteration.
Structure Layout (0x418 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x00 | vtable* | IndexList_Dtor (0x4D875C) |
| +0x04 | int | Count (number of elements) |
| +0x08 | int[256] | Free index table (0x400 bytes, 256 entries) |
| +0x408 | int | Next free index (iterator, wraps at 0xFF) |
| +0x40C | void** | Data pointer (malloc'd array of void*, count*4 bytes) |
| +0x410 | int | Capacity hint (0 on init) |
| +0x414 | int | Sorted mode flag (1=sorted, 0=unsorted) |
Key Functions
| Address | Function | Xrefs | Description |
|---|---|---|---|
| 0x453210 | AthenaList_Init | 112 | Initialize list with sorted flag |
| 0x453280 | AthenaList_Free | 68 | Free data array, reset count |
| 0x4532B0 | AthenaList_NextIndex | 256 | Get next free index (wraps at 255) |
| 0x4532E0 | AthenaList_SortedInsert | 2 | Insert in sorted order (when +0x414=1) |
| 0x453610 | AthenaList_ContainsValue | 10 | Check if value exists |
| 0x453640 | AthenaList_FindByValue | 4 | Find value, return index |
| 0x4536A0 | AthenaList_GetSize | 60 | Return count at +0x04 |
| 0x4536B0 | AthenaList_InsertAt | 7 | Insert at specific index |
| 0x453780 | AthenaList_Append | 22 | Append raw (no sorted check) |
| 0x453810 | AthenaList_Append | 301 | Append with sorted-mode dispatch |
| 0x453820 | AthenaList_MergeSorted | 10 | Merge two sorted lists |
| 0x467E40 | AthenaList_Ctor | 4 | Constructor |
| 0x46B1A0 | AthenaListObj_ctor | 7 | Object list constructor |
| 0x475730 | AthenaList_WriteToFile | 1 | Serialize list to file |
| 0x489280 | AthenaList_FreeAllChunks | 3 | Free all chunks |
| 0x489540 | AthenaList_SplitChunk | 3 | Split a chunk |
| 0x488710 | AthenaList_IterateNext | 15 | Get next item in iteration |
| 0x4897A0 | AthenaList_WriteDword | 3 | Write DWORD to list file |
Initialization (AthenaList_Init)
AthenaList_Init(this, sorted_flag):
this->sorted = sorted_flag; // +0x414
this->count = 0; // +0x04
this->next_index = 0; // +0x08..+0x408 (256 entries zeroed)
this->vtable = &IndexList_Dtor;
for (i = 0; i < 256; i++) // Clear free index table
this->free_table[i] = 0;
this->iter_idx = 0; // +0x408
Append (AthenaList_Append, 301 xrefs)
AthenaList_Append(this, value):
if (this->sorted != 0) // +0x414
return AthenaList_SortedInsert(this, value);
if (this->count == 0) { // +0x04
this->count = 1;
this->data = malloc(4); // +0x40C
// Clear free index table
for (i = 0; i < 256; i++)
this->free_table[i] = 0;
this->data[0] = value;
} else {
this->count++;
this->data = realloc(this->data, this->count * 4);
this->data[this->count - 1] = value;
}
Free (AthenaList_Free)
AthenaList_Free(this):
if (this->data != NULL) // +0x40C
free(this->data);
this->data = NULL;
this->count = 0; // +0x04
NextIndex (AthenaList_NextIndex, 256 xrefs)
AthenaList_NextIndex(this):
this->iter_idx++; // +0x408
if (this->iter_idx > 0xFF) // Wraps at 255
this->iter_idx = 1;
return this->iter_idx;
Iteration Pattern (from decompilations)
// Standard iteration pattern used throughout the codebase:
int idx = AthenaList_NextIndex(&list);
void *item = (list.count < 1) ? NULL : list.data[0];
while (item != NULL) {
// Process item
idx = list.free_table[idx];
if (list.count <= idx) break;
item = list.data[idx];
list.free_table[idx]++;
}
AthenaList_Free(&list);
Texture System
Texture Structure (0x74 bytes)
Created by Graphics_FindOrCreateTexture (0x455C50):
| Offset | Type | Description |
|---|---|---|
| +0x00 | vtable* | Texture vtable |
| +0x08 | char* | Texture name/path (strdup'd) |
| +0x0C | void** | D3D texture pointer array (from +0x6F0 in list) |
| +0x10 | int | Reference count (incremented on cache hit) |
| +0x14-0x70 | ... | D3D texture data, mip levels, format info |
Graphics_FindOrCreateTexture (0x455C50)
7 xrefs — texture loading with caching:
Texture* Graphics_FindOrCreateTexture(GfxEngine* this, char* path, char use_cache) {
if (use_cache == 1) {
// Linear search through texture list at this+0x2E4
for each texture in this->texture_list:
if (stricmp(texture->name, path) == 0) {
texture->refcount++; // +0x10
return texture; // Cache hit
}
}
// Cache miss: load new texture
Texture* tex = operator_new(0x74);
Texture_LoadWithMips(tex, this, path);
AthenaList_Append(this + 0x2E4, tex); // Add to cache
return tex;
}
Texture Cache Location
- GfxEngine+0x2E4: AthenaList texture cache (all loaded textures)
- GfxEngine+0x6F0: Texture data array
- Lookup: case-insensitive strcmp (stricmp)
- Key: texture file path (e.g., "textures\hammy1.png")
Texture Loading
- PNG/BMP files loaded via D3DXCreateTextureFromFile
- Alpha channel enabled for PNG
- Mipmaps generated via Texture_LoadWithMips
- 7 xrefs to FindOrCreateTexture
AthenaString — Dynamic String
| Address | Function | Xrefs | Description |
|---|---|---|---|
| 0x473500 | AthenaString_AssignCStr | 75 | Assign from C string |
| 0x4736B0 | AthenaString_dtor | 85 | Destructor |
| 0x473A50 | AthenaString_AssignCRLF | 21 | Assign with CRLF handling |
| 0x4BAE43 | AthenaString_SprintfToBuffer | 71 | sprintf to internal buffer |
| 0x4BBDFD | AthenaString_Sprintf | — | Format string |
| 0x466C70 | AthenaString_Format | — | Format with args |
| 0x4737F0 | AthenaString_Assign | — | Copy assignment |
| 0x473940 | FontFormatString_Parse | — | Parse printf-style formats |
AthenaString Structure (estimated 0x1C bytes)
| Offset | Type | Description |
|---|---|---|
| +0x00 | vtable* | AthenaString vtable |
| +0x04 | char* | String data (malloc'd) |
| +0x08 | int | Length |
| +0x0C | int | Capacity |
| +0x10 | int | Ref count (if shared) |
Hash Table (MESHWORLD Lookup)
The game uses a hash table for level object lookup (Scene+0x8AC). Objects
are found by name strings like "START0-0", "SAFESPOT", "CAMERALOCUS".
Key hash table functions:
- Scene_FindObjectByName: resolves mesh name to object
- Used by Scene_SpawnBallsAndObjects for start positions
- Used by Level_InitScene for camera targets (CAMERALOCUS, CAMERALOOKAT)
Graphics Utility Functions
| Address | Function | Xrefs | Description |
|---|---|---|---|
| 0x455D60 | Graphics_DrawScreenRect | 63 | Draw 2D rectangle on screen |
| 0x455110 | Graphics_ApplyMaterialAndDraw | — | Set material + draw mesh |
| 0x454AB0 | Graphics_SetProjection | 31 | Set perspective projection |
| 0x454F10 | Graphics_SetViewport | 8 | Set viewport dimensions |
| 0x453B50 | Graphics_BeginFrame | — | Start frame, clear buffers |
| 0x455A90 | Graphics_PresentOrEnd | — | Present frame, swap buffers |
| 0x453900 | Graphics_ClearViewport | — | Clear color/depth |
| 0x453970 | Graphics_SetCullMode2 | — | Set cull mode |
| 0x4539A0 | Graphics_SetViewportZ | — | Set depth range |
CRT / Standard Library Usage
The game uses the following CRT patterns:
- malloc/realloc/free for dynamic arrays (AthenaList)
- operator_new/operator_delete for object allocation
- fopen/fread/fclose for file I/O (font.description, .mesh, .meshworld)
- _open with O_RDONLY (0x8000) for font loading
- stricmp for case-insensitive string comparison (texture cache, menu items)
- sprintf for string formatting (AthenaString_Sprintf)
- GetTickCount for timing (game loop)
- PeekMessageA/TranslateMessage/DispatchMessageA for Windows message loop
- Sleep(0) for yielding between frames
🔗 Related Documents
Critical Function Deep Analysi
Critical Function Deep Analysis
Two functions were flagged as CRITICAL misnomers during the full decompilation pass.
Each was analyzed three times to verify accuracy. This document records the verified
findings.
1. TimerDisplay (0x004298C0) — Actually: App_ResourceLoader
Misnomer Summary
| Field | Value |
|---|---|
| Current name | TimerDisplay |
| Correct name | App_ResourceLoader (or LoadingScreenGadget_Factory) |
| Severity | CRITICAL — name is completely unrelated to function behavior |
| Evidence | Zero timer logic. Loads 195 game resources via vtable dispatch. |
Calling Convention
void __fastcall TimerDisplay(int param_1);
param_1(ECX,__fastcall): Pointer to the App struct (the global
application object, ~2328 bytes). Not a timer, not a display widget.- Returns: void. All output is written through the App struct.
The function pointer 0x004298C0 is stored in a dispatch table at VA 0x004D2700
in the .rdata section, alongside other App initialization functions:
App_FrameTick (0x0046C9E0), App_SetTitleString (0x0046CB70), etc.
What It Actually Does
Step 1 — Allocate LoadingScreenGadget (lines 20-28):
this = operator_new(0x3628); // Allocate 13,864 bytes
piVar1 = LoadingScreenGadget_Ctor(this, param_1); // Construct
*(int**)(param_1 + 0x22c) = piVar1; // Store gadget pointer at App+0x22C
*(undefined4*)(param_1 + 0x31c) = 0; // Clear font slot
Creates a LoadingScreenGadget object (0x3628 = 13,864 bytes) and stores it at
App+0x22C. This gadget is the master resource manager that owns all game assets.
Step 2 — Load 195 resources via vtable dispatch:
The gadget's vtable (*piVar1) has 7 resource-loading methods at different offsets.
Each call writes a resource handle into a specific slot in the App struct.
| vtable Offset | Method Type | Count | App Slots Used | Resources |
|---|---|---|---|---|
| +0x58 | Texture loader (simple) | 59 | +0x318–0x438 | titletext, hammy1-3, blueblot, goal.png, timerblot.png, star.png, dust.png, chrome.png, medals, tourney-*.png, etc. |
| +0x48 | Texture loader (with flags) | 37 | +0x27C–0x314 | sign-bewarethetar, goal.png, goal-lit.png, goal-mirrored.png, locktile.png, arrow1.png, checker textures (pink/blue/green/red/orange), brick textures, etc. |
| +0x5C | Font loader | 3 | +0x318–0x328 | showcardgothic28, arialnarrow12bold, showcardgothic72, showcardgothic14, showcardgothic16 |
| +0x4C | Mesh loader | 24 | +0x244–0x5C4 | Sphere, SphereBreak1-2, Hamster-Waiting, Hamster-trot1-3, 8Ball, FunBall, Bell, Dizzy, RBGlare, Sphere+Tar, tarbubble, fanblades, fanbody, sawblade, sawface1-2, dawgshoe1-2, dawgshadow, GlassBonus, GlassBonus-Smashed, mousetrapshadow |
| +0x50 | Level loader | 7 | +0x570–0x5C8 | MouseTrap, Secret, Secret-Unlock, Level4-Trapdoor1-2, PopupSign, Level6-Lifter |
| +0x54 | level reference copier | 4 | +0x574–0x5A0 | Copies level handles from one App slot to another (aliases) |
| +0x60 | Sound loader | 61 | +0x43C–0x52C | collide(10ch), roll(10ch), whistle(1ch), bumper(10ch), ballbreak(5ch), ballbreaksmall(5ch), thwomp(2ch), snap(2ch), popup(2ch), dropin(2ch), dropinshort(2ch), popout(2ch), pipebump1-3(10ch each), gearclank(20ch), bridgeslam(2ch), platformtick(5ch), gluestuck(5ch), bubble1-2(5ch), wheelcreak(2ch), catapult(2ch), trapdoor(2ch), fwing(2ch), clink(3ch), whoosh(3ch), chomp(1ch), fan-start(10ch), fan-blow(10ch), crack(2ch), crumble(2ch), sawstartup(2ch), sawcut(2ch), minipop(5ch), bell(3ch), zip(2ch), ting(20ch), shrink(3ch), grow(3ch), tweet(3ch), creakyplatform(20ch), wubba(5ch), saw(2ch), sawspeedy(2ch), dawgstep1-2(10ch), dawgsmash(10ch), sizzle(2ch), explode(3ch), vac-o-sux(3ch), speedcylinder(2ch), bonuspop(5ch), buzzbonus(1ch), breakbridge(1ch), unlock(1ch), NeonRide(1ch), NeonFlicker(50ch), ZoopDown(2ch), LightsOff(2ch), GlassBonus(2ch) |
Step 3 — Merge and register (lines 246-249):
Menu_MergeAllLists(*(int*)(param_1 + 0x22c)); // Merge all resource lists
Scene_AddObject(*(void**)(param_1 + 0x184), // Add to scene at App+0x184
*(int**)(param_1 + 0x22c));
Merges all loaded resource lists into the gadget and adds the gadget as a scene
object so it participates in the render/tick loop.
Summary
This function is the master game asset preloader. It creates the
LoadingScreenGadget that owns every font, texture, mesh, level, and sound
effect in the game. The name TimerDisplay is completely wrong — the only
timer-related string is "timerblot.png" (a texture used for timer display,
not the function itself).
Parameters
| Parameter | Type | Direction | Meaning |
|---|---|---|---|
param_1 |
int (App*) |
IN | Pointer to the global App struct |
| (no output params) | — | — | Results stored via App struct fields |
2. DispatchCollisionEvents (0x0040C5D0) — Actually: LevelCollisionEventHandler
Misnomer Summary
| Field | Value |
|---|---|
| Current name | DispatchCollisionEvents |
| Correct name | LevelCollisionEventHandler (or HandleCollisionEvent) |
| Severity | CRITICAL — name describes 1 of 18 event types handled |
| Evidence | 28 call sites, 18 event branches, 0 allocations (doesn't "create" anything) |
Calling Convention
void __thiscall DispatchCollisionEvents(void *this, int *param_1, int *param_2);
Verified by decompiling TowerCollisionEvents (0x0040DCD0), which calls
DispatchCollisionEvents with the same parameters it receives:
// In TowerCollisionEvents(this, ball, collObj):
// ... handles E:CATAPULTBOTTOM, E:OPENSESAME, N:TRAPDOOR, E:BITE, E:MACETRIGGER, N:MACE ...
// ... then falls through to:
DispatchCollisionEvents(this, ball, collObj);
Parameters
| Parameter | Register | Type | Direction | Meaning |
|---|---|---|---|---|
this |
ECX | void* (Scene/Board) |
IN | The containing scene or board object |
param_1 |
— | int* (Ball*) |
IN/OUT | The ball that triggered the collision. Read for position, player index, cooldowns, CollisionMesh. Written to for velocity, flags, timers, scores. |
param_2 |
— | int* (CollisionEvent*) |
IN | The collision object data. param_2[1]+0x864 = event string (the N:/E: tag). *param_2+0x47C = rotator ID. |
| (returns) | EAX | void | — | No return value |
Ball Struct Offsets Accessed (param_1)
| Offset | Type | Field Name | Access | Used By |
|---|---|---|---|---|
| +0x059 (param_1[0x59]) | float | ball.pos_x | READ | E:JUMP, N:TARPIT, PIPEBONK (3D sound position) |
| +0x05A (param_1[0x5A]) | float | ball.pos_y | READ | E:JUMP, N:TARPIT, PIPEBONK |
| +0x05B (param_1[0x5B]) | float | ball.pos_z | READ | E:JUMP, N:TARPIT, PIPEBONK |
| +0x0B3 (param_1[0xB3]) | byte | ball.in_tar | READ/WRITE | N:TARPIT (set=1) |
| +0x0B4 (param_1[0xB4]) | float | ball.tar_entry_y | WRITE | N:TARPIT (stores entry Y) |
| +0x0B6 (param_1[0xB6]) | int | ball.water_timer | WRITE | N:WATER (=10) |
| +0x0A7 (param_1[0xA7]) | float | ball.vel_x | WRITE | E:JUMP (=0.002) |
| +0x0A8 (param_1[0xA8]) | float | ball.vel_y | WRITE | E:JUMP (=1.0) |
| +0x0CB (param_1+0xCB) | AthenaList | ball.action_list | READ/WRITE | E:ACTION (ONCE tracking) |
| +0x018 (param_1[6]) | int | ball.player_index | READ | E:LIMIT, E:ACTION, N:GOAL (0-3) |
| +0x014 (param_1[5]) | int* | ball.board_ptr | READ | E:LIMIT (ArenaBoard) |
| +0x028 (param_1[0x69>>2]) | int* | ball.collision_mesh | READ | E:TRAJECTORY, DROPIN, N:MOUSETRAP |
| +0x053 (param_1[0x53]) | byte | ball.goal_reached | READ | N:GOAL (gate) |
| +0x1DA (param_1+0x1DA) | byte | ball.enabled/active | READ/WRITE | N:GOAL (gate), E:LIMIT, N:TARPIT (=0) |
| +0x1F2 (param_1[0x1F2]) | int | ball.dropin_cooldown | READ/WRITE | DROPIN (=50) |
| +0x1F5 (param_1[0x1F5]) | int | ball.zip_cooldown | READ/WRITE | ZIP event (=50) |
| +0x1F7 (param_1[0x1F7]) | int | ball.jump_cooldown | READ/WRITE | E:JUMP (=10) |
| +0x1F8 (param_1[500]) | int | ball.pipebonk_cooldown | READ/WRITE | PIPEBONK (=10) |
| +0x1F4 (param_1[499]) | int | ball.popout_cooldown | READ/WRITE | POPOUT (=50) |
| +0x202 (param_1[0x202]) | int | ball.impact_timer | WRITE | E:JUMP, N:NOCONTROL (=10) |
| +0x2D5 (param_1+0x2D5) | byte | ball.in_water | WRITE | N:WATER (=1) |
| +0x2E9 (param_1+0x2E9) | byte | ball.impact_shatter | WRITE | E:LIMIT (=1) |
| +0x30B (param_1+0x30B) | byte | ball.safe_switch_flag | WRITE | E:SAFESWITCH (=0 if no paren) |
| +0xC2C (param_1+0xC2C) | char[] | ball.switch_data | WRITE | E:SAFESWITCH (copies parenthesized data) |
CollisionMesh Offsets (param_1[0x69])
| Offset | Type | Field | Used By |
|---|---|---|---|
| +0xCA4 | float | velocity_x | E:TRAJECTORY (write), DROPIN (read), N:MOUSETRAP (read/write) |
| +0xCA8 | float | velocity_y | E:TRAJECTORY (write), DROPIN (read), N:MOUSETRAP (write=15.0) |
| +0xCAC | float | velocity_z | E:TRAJECTORY (write), DROPIN (read), N:MOUSETRAP (read/write) |
The 18 Event Branches
The function reads the event string from param_2[1]+0x864 and dispatches
based on its content. All comparisons are case-insensitive.
N: prefix events (Named collision zones)
| # | Event String | Comparison | Condition | Action | Ball Fields Written | Points |
|---|---|---|---|---|---|---|
| 1 | N:SECRET |
strnicmp(8) | — | Calls Rotator_MarkTriggered(rotator_id) |
none | 0 |
| 2 | N:UNLOCKSECRET |
strnicmp(14) | — | Calls CheckArenaUnlock(this) |
none | 0 |
| 3 | E:NODIZZY |
strnicmp(9) | — | Parses <TIME>N</TIME> XML. Calls Ball_DizzyImmunity(ball, N). |
Ball_DizzyImmunity | 0 (records time) |
| 4 | E:SAFESWITCH |
strnicmp(12) | — | Finds ( in string. Copies parenthesized data to ball+0xC2C. If no (, clears ball+0x30B=0. |
+0xC2C, +0x30B | 0 |
| 5 | E:LIMIT |
stricmp | — | Clears ball+0x1DA=0, sets ball+0x2E9=1. Tracks arena knockoff counts for all 4 players at ArenaBoard+0x47B4–0x47C0. Checks each player's ball completion flags at App+0x5D7/0x677/0x717/0x7B7 and ball validity at App+0x5DC/0x67C/0x71C/0x7BC. |
+0x1DA, +0x2E9, ArenaBoard counters | 0 |
| 6 | E:BREAK |
stricmp | — | Calls ball->vtable[0x20]() — virtual break method. |
vtable dispatch | 0 |
| 7 | E:JUMP |
stricmp | ball.jump_cooldown < 1 |
Plays 3D jump sound at ball position. Sets vel_x=0.002, vel_y=1.0, impact=10, jump_cooldown=10. |
+0x1F7=10, +0xA7=0.002, +0xA8=1.0, +0x202=10 | +200 |
| 8 | E:ACTION |
strnicmp(8) | — | Parses XML. <ONCE>TRUE</ONCE>: adds event to ball.action_list if not present (one-shot gate). <SCORE>N</SCORE>: adds N × difficulty_modifier to player score at App+0x5E4+playerIndex×0xA0. Updates ball name with score. |
+0xCB (list), App+0x5E4 | N × difficulty |
| 9 | E:TRAJECTORY |
strnicmp(12) | — | Parses <X>f</X> <Y>f</Y> <Z>f</Z> XML. Writes Vec3 to CollisionMesh+0xCA4/0xCA8/0xCAC (velocity override). |
CM+0xCA4/CA8/CAC | 0 |
| 10 | N:NOCONTROL |
stricmp | — | Sets ball.impact_timer = 10 (disables input). |
+0x202=10 | 0 |
| 11 | N:WATER |
stricmp | — | Sets ball.in_water = 1, ball.water_timer = 10. |
+0x2D5=1, +0xB6=10 | 0 |
| 12 | N:TARPIT |
stricmp | — | If not already in tar: plays tar sound, stores ball.pos_y as entry height. Sets ball.in_tar=1, clears ball+0x1DA=0. |
+0xB3=1, +0xB4=pos_y, +0x1DA=0 | 0 |
| 13 | N:GOAL |
manual(7 bytes) | ball.goal_reached==0 AND ball.enabled!=0 |
RACE FINISH. Plays "Goal!" music. Sets scene+0xCD0=1. For multiplayer: copies race timer. Sets App+0x5D6+playerIdx×0xA0=1 (goal reached). Swaps goal textures unlit→lit. If mirror mode: swaps mirrored textures too. Sets App+0x5F0+playerIdx×0xA0=1 (race finished). Updates status text to "Update". |
scene+0xCD0, App+0x5D6/5FC/5F0 (+playerIdx×0xA0) | 0 |
| 14 | N:MOUSETRAP |
manual(12 bytes) | — | Reads ball velocity from CollisionMesh+0xCA4/CA8/CAC. Normalizes it. Sets vel_y = 15.0. Scales entire vector by 20.0 (DAT_004cf370). Writes back. Iterates rotator list at scene+0x1D3C, finds matching rotator by ID (rotator+0x10D4 == *param_2+0x47C). On match: plays collision sound, adds rotator to scene+0x2578 (collision list). |
CM+0xCA4/CA8/CAC (velocity modified), scene+0x2578 (list append) | 0 |
X: prefix events (Anonymous collision events, matched by suffix only)
These use stricmp(event+2, ...) — skipping the 2-character prefix,
so they match any X: prefix (e.g., E:DROPIN, N:DROPIN, etc.).
| # | Event Suffix | Condition | Action | Ball Fields Written | Points |
|---|---|---|---|---|---|
| 15 | DROPIN |
velocity_mag in range (0.0, >2.0) AND dropin_cooldown < 1 |
Plays dropin sound. Sets cooldown=50. | +0x1F2=50 | +200 |
| 16 | PIPEBONK |
pipebonk_cooldown < 1 |
Generates 2 random numbers. Plays random pipe bump sound (pipebump1/2/3). Sets cooldown=10. | +0x1F8=10 | +100 |
| 17 | POPOUT |
popout_cooldown < 1 |
Plays popout sound. Sets cooldown=50. | +0x1F4=50 | +100 |
| 18 | ZIP |
zip_cooldown < 1 |
Plays zip sound. Sets cooldown=50. | +0x1F5=50 | 0 |
Verified Constants
| Address | Value | Type | Used By |
|---|---|---|---|
0x004CF368 |
0.0 | float | DROPIN: velocity upper bound (velocity must be < 0.0, i.e., the < 0.0 == == 0.0 check is always true) |
0x004CF370 |
20.0 | float | N:MOUSETRAP: velocity scale factor |
0x004CF48C |
2.0 | float | DROPIN: minimum velocity magnitude (must be > 2.0) |
0x41700000 |
15.0 | float | N:MOUSETRAP: velocity Y override |
0x3B03126F |
0.002 | float | E:JUMP: velocity X drift |
Call Sites (28 total)
TowerCollisionEvents (0x0040DCD0) — primary level collision dispatcher
ExpertCollisionEvents (0x0040EA6B) — arena mode collision dispatcher
HandleArenaCollisionEvents (0x00412850) — spinner/bumper/launch collision
NeonCollisionEvents (0x00410E6F) — arena limit collision
SinkPlatformArenaCollisionEvents (0x00413C09) — sinking platform collision
+ 23 other Create* and collision handler functions
+ 2 DATA references (vtable entries)
Summary
DispatchCollisionEvents is the universal level collision event handler. It is called
as a fall-through from TowerCollisionEvents and ExpertCollisionEvents after
those functions handle their own specific events. It dispatches 18 different event
types based on the collision event string:
- Race progression: N:GOAL (finish), N:SECRET (mark secret found)
- Arena mode: E:LIMIT (track knockoffs), N:UNLOCKSECRET (unlock check)
- Ball physics: E:JUMP (impulse), E:TRAJECTORY (velocity override),
N:MOUSETRAP (deflect), E:BREAK (shatter) - Environmental: N:WATER (water zone), N:TARPIT (tar zone), N:NOCONTROL (stun)
- Pipe events: DROPIN, PIPEBONK, POPOUT, ZIP (sound + score)
- Scoring/tracking: E:ACTION (once-gated score), E:NODIZZY (time record)
- Switches: E:SAFESWITCH (copy switch data to ball)
The name DispatchCollisionEvents describes only the E:NODIZZY branch (branch #3), which
is one of 18. The function creates nothing — it is a pure event handler that reads
the collision event string and dispatches side effects to the ball, App, and
scene structs.
🔗 Related Documents
Custom Event Plane Implementat
Custom Event Plane Implementation Guide
Overview
This document explains how to add a brand-new event plane type to Hamsterball using a DLL proxy mod. The game's event system is entirely string-matching based — there is no registration table or enum. Adding a new event type means intercepting the string check at runtime.
Prerequisites:
- MESHWORLD file with
E:prefixed geometry (you handle this) - bass.dll proxy DLL compiled with MinGW (this guide covers it)
- Understanding of the collision dispatch pipeline (see EVENT_PLANES.md)
1. The Dispatch Pipeline (What You're Hooking Into)
When the ball intersects an E: named collision mesh, this is the call chain:
Ball_FallUpdate (0x408830, vtable[65] at offset 0x104)
│
├─ Collision_TraverseSpatialTree (0x465EF0)
│ └─ Finds intersecting mesh buffers → populates physics+0x848 list
│
└─ For each collision entry in physics+0x848 list:
└─ board->vtable[0x1D] (offset +0x74)
│
├─ TowerCollisionEvents (0x40DCD0) — Tower board (optional override)
├─ ExpertCollisionEvents (0x40E6A0) — arenas (optional override)
└─ DispatchCollisionEvents (0x40C5D0) — shared base, ALWAYS called last
Key insight: DispatchCollisionEvents is the universal chokepoint. Every level-specific handler (TowerCollisionEvents, ExpertCollisionEvents, and the 25+ unnamed board-specific handlers) processes its own E: events, then falls through to DispatchCollisionEvents as a catch-all. Hooking this one function gives you coverage for ALL board types and ALL game modes.
Calling Convention
void __thiscall DispatchCollisionEvents(void *board, int *ball, int *coll_entry);
- ECX =
board(the Board/scene object — type varies by level) - [ESP+4] =
ball(pointer to Ball struct) - [ESP+8] =
coll_entry(pointer to 2-element array:[0]=scene_obj, [1]=mesh_buffer)
The event name string is at:
char *event_name = *(char **)(coll_entry[1] + 0x864);
Per-Ball State
Each ball has a player index at ball+0x18 (int, 0–3 for 4 players). This is the same field the game itself uses for per-player scoring in E:LIMIT and N:GOAL. Use it to index a global array:
static int g_my_flag[4]; // per-player state
void hook(...) {
int *ball = ...; // from hook params
int player_idx = ball[6]; // ball+0x18, int* stride = 0x18
g_my_flag[player_idx] = 1;
}
2. Hook Architecture
MinGW __thiscall Workaround
MinGW doesn't support __thiscall directly. Use __fastcall with a dummy EDX parameter:
// __thiscall(this, arg1, arg2) == __fastcall(this, dummy_edx, arg1, arg2)
// Both use ECX for first arg, callee cleans 8 bytes of stack.
typedef void (__fastcall *handler_t)(void *this_, void *edx_dummy, void *ball, void *coll_entry);
Inline Hook (JMP Detour)
The hook overwrites the first 5 bytes of DispatchCollisionEvents with a JMP to your handler. A trampoline saves the original 5 bytes + JMP back to original+5:
Original: [orig 5 bytes] [rest of function]
After hook: [JMP to hook] [rest of function]
Trampoline: [orig 5 bytes] [JMP to original+5]
Your handler calls the trampoline to execute the original function.
ASLR Safety
Hamsterball.exe loads at base 0x400000 (no ASLR on this PE), but the hook code computes the real base at runtime:
DWORD base = (DWORD)GetModuleHandleA(NULL);
void *target = (void *)(0x0040C5D0 + (base - 0x00400000));
3. Reading Event Data
Safe Event Name Access
Always use IsBadReadPtr before reading game memory (MinGW doesn't support __try/__except):
static const char *get_event_name(void *coll_entry) {
if (!coll_entry) return "(null)";
if (IsBadReadPtr(coll_entry, 12)) return "(bad-ptr)";
int *pair = (int *)coll_entry;
int mesh_buffer = pair[1];
if (!mesh_buffer || IsBadReadPtr((void *)mesh_buffer, 0x868))
return "(bad-mesh)";
int name_ptr = *(int *)(mesh_buffer + 0x864);
if (!name_ptr || IsBadReadPtr((void *)name_ptr, 1))
return "(bad-name)";
return (const char *)name_ptr;
}
Reading Ball Position
static void get_ball_pos(void *ball, float *x, float *y, float *z) {
*x = *y = *z = 0.0f;
if (!ball || IsBadReadPtr(ball, 0x170)) return;
*x = *(float *)((char *)ball + 0x164);
*y = *(float *)((char *)ball + 0x168);
*z = *(float *)((char *)ball + 0x16C);
}
Parsing XML-Style Tag Parameters
If your event name includes parameters (e.g., E:MYFLAG<DURATION>100</DURATION>), parse them using the game's own MWParser_ReadTag function at 0x0040xxxx (search Ghidra for MWParser_ReadTag). Alternatively, parse manually with strchr and strstr:
// Parse <TAG>value</TAG> from event name string
static float parse_tag_float(const char *name, const char *tag_name) {
char open_tag[64], close_tag[64];
snprintf(open_tag, sizeof(open_tag), "<%s>", tag_name);
snprintf(close_tag, sizeof(close_tag), "</%s>", tag_name);
const char *start = strstr(name, open_tag);
if (!start) return 0.0f;
start += strlen(open_tag);
const char *end = strstr(start, close_tag);
if (!end) return 0.0f;
char buf[32];
int len = end - start;
if (len >= sizeof(buf)) len = sizeof(buf) - 1;
memcpy(buf, start, len);
buf[len] = 0;
return (float)atof(buf);
}
4. Writing Effects
Ball Struct Offsets for Event Effects
| Offset | Type | Name | Used By |
|---|---|---|---|
| +0x18 | int | player_index | E:LIMIT, N:GOAL (per-player scoring) |
| +0xA7 | float | vert_velocity | E:JUMP (0.025 = upward force) |
| +0xA8 | byte | vert_velocity_on | E:JUMP (= 1, enables vertical velocity) |
| +0x164 | float | pos_x | Ball position X |
| +0x168 | float | pos_y | Ball position Y (vertical, Y-up) |
| +0x16C | float | pos_z | Ball position Z |
| +0x1F7 | int | impact_counter | E:JUMP (10 = bounce cooldown timer) |
| +0x202 | int | freeze_counter | E:JUMP / N:NOCONTROL (10 = freeze input) |
| +0x2D5 | byte | in_water | N:WATER (1 = water physics active) |
| +0xB3 | byte | in_tar | N:TARPIT (1 = tar physics active) |
| +0xB6 | int | zone_timer | N:WATER (10 = effect timer in frames) |
| +0x1DA | byte | velocity_flag | E:LIMIT / N:TARPIT (0 = clear velocity) |
Calling Game Functions
To trigger existing game effects (sounds, scoring, etc.), call game functions directly:
// Ball_DizzyImmunity(ball, score) — awards points with difficulty modifier
typedef void (__thiscall *Ball_DizzyImmunity_t)(void *ball, long score);
static Ball_DizzyImmunity_t Ball_DizzyImmunity = NULL;
// Sound_Play3D(sound_ptr, x, y, z) — positional audio
typedef void (__cdecl *Sound_Play3D_t)(void *sound, float x, float y, float z);
static Sound_Play3D_t Sound_Play3D = NULL;
// Sound_PlayChannel(channel) — non-positional audio
typedef void (__cdecl *Sound_PlayChannel_t)(int channel);
static Sound_PlayChannel_t Sound_PlayChannel = NULL;
Sound offsets (from board→App at board+0x878→App):
| App Offset | Sound |
|---|---|
| +0x460 | Drop-in sound |
| +0x464 | Catapult sound |
| +0x468 | Pop-out sound |
| +0x46C+idx*4 | Pipe bonk sounds (3 variants) |
| +0x484 | Tar sound |
| +0x49C | Jump sound |
| +0x4CC | Popout sound |
Board/Scene Access
From the board parameter (ECX in __thiscall):
void *app = *(void **)((char *)board + 0x878); // App pointer
int difficulty = *(int *)((char *)app + 0x23C); // 0=Pipsqueak, 1=Normal, 2=Frenzied
5. Complete Example: Adding E:MYFLAG
This example adds a custom event plane that:
- Sets a per-player flag when touched
- Plays the jump sound
- Awards 100 points
- Applies a small upward bounce
Full Source (custom_event.c)
/*
* Hamsterball Custom Event Plane DLL — E:MYFLAG
*
* Hooks DispatchCollisionEvents (0x40C5D0) to intercept custom E: event names.
* Built as a bass.dll proxy (loads automatically with the game).
*
* Build:
* i686-w64-mingw32-gcc -shared -o bass.dll custom_event.c \
* -lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
* -Wl,--add-stdcall-alias
*
* Install: Copy bass.dll next to Hamsterball.exe (rename original bass.dll first).
*/
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <string.h>
#include <stdlib.h>
/* ── Constants ────────────────────────────────────────────────────────── */
#define GAME_BASE 0x00400000
#define ADDR_DispatchCollisionEvents (GAME_BASE + 0x0000C5D0)
#define TRAMP_SIZE 16
/* ── Types ────────────────────────────────────────────────────────────── */
/* __thiscall workaround: __fastcall with dummy EDX */
typedef void (__fastcall *handler_t)(void *this_, void *edx_dummy,
void *ball, void *coll_entry);
/* Game function typedefs */
typedef void (__thiscall *Ball_DizzyImmunity_t)(void *ball, long score);
typedef void (__cdecl *Sound_Play3D_t)(void *sound, float x, float y, float z);
/* ── Globals ─────────────────────────────────────────────────────────── */
static handler_t g_orig_DispatchCollisionEvents = NULL;
static unsigned char g_tramp[TRAMP_SIZE];
/* Per-player custom flag state (indexed by ball+0x18, max 4 players) */
static int g_my_flag[4] = {0, 0, 0, 0};
/* Game function pointers (resolved at init) */
static Ball_DizzyImmunity_t fn_Ball_DizzyImmunity = NULL;
static Sound_Play3D_t fn_Sound_Play3D = NULL;
/* ── Safe memory helpers ──────────────────────────────────────────────── */
static const char *get_event_name(void *coll_entry) {
if (!coll_entry) return NULL;
if (IsBadReadPtr(coll_entry, 12)) return NULL;
int *pair = (int *)coll_entry;
int mesh_buffer = pair[1];
if (!mesh_buffer || IsBadReadPtr((void *)mesh_buffer, 0x868))
return NULL;
int name_ptr = *(int *)(mesh_buffer + 0x864);
if (!name_ptr || IsBadReadPtr((void *)name_ptr, 1))
return NULL;
return (const char *)name_ptr;
}
static void get_ball_pos(void *ball, float *x, float *y, float *z) {
*x = *y = *z = 0.0f;
if (!ball || IsBadReadPtr(ball, 0x170)) return;
*x = *(float *)((char *)ball + 0x164);
*y = *(float *)((char *)ball + 0x168);
*z = *(float *)((char *)ball + 0x16C);
}
static int get_player_index(void *ball) {
if (!ball || IsBadReadPtr(ball, 0x20)) return 0;
return *(int *)((char *)ball + 0x18);
}
/* ── Custom event handler ────────────────────────────────────────────── */
static void handle_my_flag(void *board, void *ball, void *coll_entry) {
/* Get player index for per-ball state */
int pidx = get_player_index(ball);
if (pidx < 0 || pidx > 3) pidx = 0;
/* Set the custom flag */
g_my_flag[pidx] = 1;
/* Play jump sound at ball position */
if (fn_Sound_Play3D) {
void *app = *(void **)((char *)board + 0x878);
if (app && !IsBadReadPtr(app, 0x4A0)) {
void *jump_sound = *(void **)((char *)app + 0x49C);
float x, y, z;
get_ball_pos(ball, &x, &y, &z);
if (jump_sound)
fn_Sound_Play3D(jump_sound, x, y, z);
}
}
/* Award 100 points */
if (fn_Ball_DizzyImmunity)
fn_Ball_DizzyImmunity(ball, 100);
/* Small upward bounce (same pattern as E:JUMP) */
if (ball && !IsBadReadPtr(ball, 0x210)) {
int *ball_ints = (int *)ball;
/* Check impact cooldown (ball+0x1F7) */
if (ball_ints[0x1F7 >> 2] < 1) {
/* Set vertical velocity */
*(float *)((char *)ball + 0xA7) = 0.025f; /* upward force */
*(unsigned char *)((char *)ball + 0xA8) = 1; /* enable flag */
ball_ints[0x202 >> 2] = 10; /* freeze input 10 frames */
ball_ints[0x1F7 >> 2] = 10; /* impact cooldown 10 frames */
}
}
}
/* ── Hook callback ───────────────────────────────────────────────────── */
void __fastcall hook_DispatchCollisionEvents(void *this_, void *edx_dummy,
void *ball, void *coll_entry) {
(void)edx_dummy;
const char *name = get_event_name(coll_entry);
if (name) {
/* Check for our custom event */
if (_stricmp(name, "E:MYFLAG") == 0) {
handle_my_flag(this_, ball, coll_entry);
/* Still call original so other events on same geometry fire.
Return here (skip original) if you want exclusive handling. */
}
/* Add more custom events here:
*
* if (_strnicmp(name, "E:CUSTOM_SPEED", 14) == 0) {
* float power = parse_tag_float(name, "POWER");
* handle_speed_pad(this_, ball, coll_entry, power);
* }
*
* if (_stricmp(name, "E:WARP") == 0) {
* handle_warp(this_, ball, coll_entry);
* return; // skip original — warp replaces all other events
* }
*/
}
/* Call original DispatchCollisionEvents for standard events */
if (g_orig_DispatchCollisionEvents)
g_orig_DispatchCollisionEvents(this_, NULL, ball, coll_entry);
}
/* ── Inline hook engine ──────────────────────────────────────────────── */
static int install_hook(void *target, void *hook, unsigned char *trampoline) {
DWORD oldProtect;
unsigned char *t = (unsigned char *)target;
if (!VirtualProtect(t, TRAMP_SIZE, PAGE_EXECUTE_READWRITE, &oldProtect))
return 0;
/* Save original bytes to trampoline */
memcpy(trampoline, t, TRAMP_SIZE);
/* Trampoline: original 5 bytes + JMP back to target+5 */
trampoline[5] = 0xE9;
*(unsigned long *)(trampoline + 6) =
(unsigned long)((char *)target + 5 - (char *)(trampoline + 5) - 5);
/* Make trampoline executable */
DWORD tp;
VirtualProtect(trampoline, TRAMP_SIZE, PAGE_EXECUTE_READWRITE, &tp);
/* Overwrite target with JMP to hook */
unsigned long rel = (unsigned long)((char *)hook - (char *)target - 5);
t[0] = 0xE9;
*(unsigned long *)(t + 1) = rel;
FlushInstructionCache(GetCurrentProcess(), target, 5);
return 1;
}
/* ── Init thread ─────────────────────────────────────────────────────── */
static DWORD WINAPI init_thread(LPVOID lpParam) {
(void)lpParam;
Sleep(2000); /* Wait for game to fully load */
/* Resolve game function addresses */
DWORD base = (DWORD)GetModuleHandleA(NULL);
DWORD offset = base - GAME_BASE;
fn_Ball_DizzyImmunity = (Ball_DizzyImmunity_t)(0x00402400 + offset);
fn_Sound_Play3D = (Sound_Play3D_t)(0x0040xxxx + offset);
/* NOTE: Sound_Play3D address needs Ghidra verification before building.
Search for "Sound_Play3D" in Ghidra function list. */
/* Install hook on DispatchCollisionEvents */
void *target = (void *)(ADDR_DispatchCollisionEvents + offset);
if (install_hook(target, hook_DispatchCollisionEvents, g_tramp)) {
g_orig_DispatchCollisionEvents = (handler_t)g_tramp;
}
return 0;
}
/* ── DLL Entry (bass.dll proxy) ──────────────────────────────────────── */
/* Forward declarations for BASS exports (see hamsterball-dll-modding skill
for the full 10-export list) */
BOOL WINAPI BASS_Init(void *a, int b, int c, void *d, void *e) { return TRUE; }
void WINAPI BASS_Free(void) {}
BOOL WINAPI BASS_Start(void) { return TRUE; }
void WINAPI BASS_Stop(void) {}
BOOL WINAPI BASS_SetConfig(int a, int b) { return TRUE; }
/* ... remaining BASS exports forwarded to original if needed ... */
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
(void)lpvReserved;
switch (fdwReason) {
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hinstDLL);
CreateThread(NULL, 0, init_thread, NULL, 0, NULL);
break;
case DLL_PROCESS_DETACH:
/* Hooks are auto-removed when process exits */
break;
}
return TRUE;
}
6. Build & Test Workflow
Cross-Compile (Linux → Windows DLL)
i686-w64-mingw32-gcc -shared -o bass.dll custom_event.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
MinGW pitfalls (from experience):
fopen()triggers C4996 error → usefopen_s()instead__try/__exceptnot supported → useIsBadReadPtr()before readsdsound.hmay need#include <mmeapi.h>before it- Always use
-static -static-libgccto bundle runtime DLLs - See skill hamsterball-dll-modding for full details
Crash Test (Wine/Xvfb)
# Copy DLL to game directory
cp bass.dll ~/hamsterball-re/originals/installed/extracted/
# Delete cached files so the game re-reads MESHWORLD
rm -f ~/hamsterball-re/originals/installed/extracted/Levels/*.cached
# Launch game on Xvfb
DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 timeout 35 wine Hamsterball.exe &
# After 35 seconds, check if process is still alive
# (most DLL crashes happen during audio init at startup)
sleep 35
if pgrep -x wine > /dev/null; then
echo "CRASH TEST PASSED"
else
echo "CRASH TEST FAILED"
fi
What crash testing validates:
- Hook address is correct (wrong address = instant crash)
- Calling convention matches (
__thiscall/__fastcall) - No stack corruption from mismatched
RET N - Trampoline preserves original instruction boundaries
IsBadReadPtrguards prevent access violations
What it does NOT validate:
- Whether the custom event actually triggers (game renders black on llvmpipe)
- Whether sound plays correctly
- Whether physics effects are correct
Visual/gameplay testing is done on real Windows by the user.
Testing on Real Windows
- Backup original
bass.dll→bass_real.dll - Copy compiled
bass.dllnext toHamsterball.exe - Delete all
*.cachedfiles in theLevels/folder - Launch the game
- Play a level containing your custom
E:MYFLAGevent plane - Verify the effect triggers (sound, score, bounce)
- Use
MessageBoxApopups in the hook for debugging if needed:
/* Debug: show popup when event fires */
char buf[256];
snprintf(buf, sizeof(buf), "E:MYFLAG hit! player=%d pos=%.1f,%.1f,%.1f",
pidx, x, y, z);
MessageBoxA(NULL, buf, "CustomEvent", MB_OK);
7. Design Patterns for Common Custom Events
One-Shot Trigger (disappears after first touch)
static int g_triggered[4] = {0};
void handle_one_shot(void *board, void *ball, void *coll_entry) {
int pidx = get_player_index(ball);
if (g_triggered[pidx]) return; /* already triggered */
g_triggered[pidx] = 1;
/* ... apply effect ... */
}
Parameterized Event (parses values from name string)
Use E:SPEEDPAD<POWER>0.5</POWER> in the MESHWORLD:
void handle_speed_pad(void *board, void *ball, void *coll_entry,
const char *name) {
/* Parse <POWER> tag */
const char *start = strstr(name, "<POWER>");
float power = 0.5f; /* default */
if (start) {
power = (float)atof(start + 7);
}
/* Apply directional force based on collision normal */
int *pair = (int *)coll_entry;
int mesh = pair[1];
if (mesh && !IsBadReadPtr((void *)mesh, 0x30)) {
float nx = *(float *)(mesh + 0x20);
float ny = *(float *)(mesh + 0x24);
float nz = *(float *)(mesh + 0x28);
/* Add force to ball velocity accumulators */
*(float *)((char *)ball + 0x170) += nx * power;
*(float *)((char *)ball + 0x174) += ny * power;
*(float *)((char *)ball + 0x178) += nz * power;
}
}
Warp Pad
static void handle_warp(void *board, void *ball, void *coll_entry) {
/* Parse <X>, <Y>, <Z> target from event name */
const char *name = get_event_name(coll_entry);
/* ... parse coordinates ... */
/* Set ball position directly */
*(float *)((char *)ball + 0x164) = target_x;
*(float *)((char *)ball + 0x168) = target_y;
*(float *)((char *)ball + 0x16C) = target_z;
/* Clear velocity to prevent slingshot */
*(float *)((char *)ball + 0x170) = 0.0f;
*(float *)((char *)ball + 0x174) = 0.0f;
*(float *)((char *)ball + 0x178) = 0.0f;
}
Cooldown-Limited Trigger (rate-limited)
Same pattern as E:JUMP — use a counter on the ball:
void handle_cooldown(void *board, void *ball, void *coll_entry) {
int *ball_ints = (int *)ball;
/* Check cooldown timer (reuse ball+0x1F7 or another offset) */
if (ball_ints[0x1F7 >> 2] > 0) return; /* still cooling down */
ball_ints[0x1F7 >> 2] = 30; /* 30-frame cooldown */
/* ... apply effect ... */
}
8. Reference: All Known Event Types
For reference, here are all existing E: and N: events the game recognizes. Custom events should use names that don't collide with these:
DispatchCollisionEvents (0x40C5D0) — Universal Events
| Event | Match | Effect |
|---|---|---|
E:NODIZZY<TIME>N</TIME> |
prefix | Anti-dizzy zone, duration in frames |
E:SAFESWITCH or E:SAFESWITCH(data) |
prefix | Copy data to ball+0xC2C |
E:LIMIT |
exact | Arena finish line tracking |
E:BREAK |
exact | Ball bounce callback (vtable[0x20]) |
E:JUMP |
exact | Bounce pad: sound + force + 200 score |
E:ACTION<ONCE>TRUE</ONCE><SCORE>N</SCORE> |
prefix | One-time score award |
E:TRAJECTORY<X>..</X><Y>..</Y><Z>..</Z> |
prefix | Set ball trajectory vector |
N:SECRET |
prefix | Mark rotator triggered |
N:UNLOCKSECRET |
prefix | Check arena unlock |
N:NOCONTROL |
exact | Disable input 10 frames |
N:WATER |
exact | Water physics flag + timer |
N:TARPIT |
exact | Tar physics flag |
N:GOAL |
exact | Race finish sequence |
N:MOUSETRAP |
exact | Deflect ball + rotator collision |
E:DROPIN |
suffix match | Sound + 200 score (cooldown 50) |
E:PIPEBONK |
suffix match | Random sound + 100 score (cooldown 10) |
E:POPOUT |
suffix match | Sound + 100 score (cooldown 50) |
TowerCollisionEvents (0x40DCD0) — Race-Only Events
| Event | Effect |
|---|---|
E:CATAPULTBOTTOM |
Launch catapult |
E:OPENSESAME |
Open trapdoor |
N:TRAPDOOR |
Activate trapdoor |
E:BITE |
Set damage=25.0 |
E:MACETRIGGER |
Activate all maces |
N:MACE |
Ball bounce on mace |
ExpertCollisionEvents (0x40E6A0) — Arena-Only Events
| Event | Effect |
|---|---|
E:CALLHAMMER |
Spawn hammer (tournament only) |
E:HAMMERCHASE |
Start hammer chase (tournament only) |
E:ALERTSAW1 / E:ALERTSAW2 |
Pre-activate saw blade |
E:ACTIVATESAW1 / E:ACTIVATESAW2 |
Full activate saw blade |
E:ALERTJUDGES |
Reset all judges |
E:SCORE<n> |
Set time on score displays |
E:BELL |
Activate bell + 500 bonus time |
E:JUMP |
Duplicate of base handler |
Other Level-Specific Events (in unnamed handlers)
| Event | Found In | Effect |
|---|---|---|
E:HEATON |
Neon level handler | Turn heat on |
E:HEATOFF |
Neon level handler | Turn heat off |
E:NOPEGS |
Toob level handler | Remove pegs |
E:PEGS |
Toob level handler | Add pegs |
E:TRAPPOP |
Toob level handler | Pop trap |
E:LAUNCH |
Tower level handler | Launch ball |
9. Key Addresses Summary
| Address | Function | Purpose |
|---|---|---|
0x0040C5D0 |
DispatchCollisionEvents |
Hook target — universal event dispatcher |
0x0040DCD0 |
TowerCollisionEvents |
Race-specific events (optional secondary hook) |
0x0040E6A0 |
ExpertCollisionEvents |
Arena-specific events (optional secondary hook) |
0x00402400 |
Ball_DizzyImmunity |
Award score to ball |
0x00465D90 |
Mesh_FindClosestCollision |
Raycast collision (for custom collision checks) |
0x00465260 |
Level_LoadCollision |
How event planes are loaded from MESHWORLD |
0x00408830 |
Ball_FallUpdate |
Physics tick that triggers collision dispatch |
0x00400000 |
Image base | RVA calculations (addr - 0x400000) |
MeshBuffer Struct (0x874 bytes)
| Offset | Type | Field |
|---|---|---|
| +0x85D | byte | interactive (1 for N: and E: prefixes) |
| +0x863 | byte | no_render (1 for E: prefix only) |
| +0x864 | char* | Event name string pointer |
Collision Entry (passed as coll_entry)
| Offset | Type | Field |
|---|---|---|
| +0x00 | void* | scene object pointer (Level/Stands) |
| +0x04 | void* | Mesh buffer pointer (has +0x864 = name string) |
10. Troubleshooting
Hook doesn't fire
- Verify the DLL is loaded (add
MessageBoxA(NULL, "loaded", "", MB_OK)inDllMain) - Check that
Sleep(2000)delay is long enough for game init - Verify hook address: decompile
DispatchCollisionEventsat0x40C5D0in Ghidra — the first 5 bytes must be a complete instruction (no mid-instruction split) - If using bass.dll proxy: ensure the original
bass.dllis renamed tobass_real.dll, not deleted
Event name is (null) or (bad-ptr)
- The
coll_entrypointer may be invalid — checkIsBadReadPtrguards - The mesh buffer at
coll_entry[1]may not have an event name (it's regular geometry, not an event plane) - Only meshes with
E:orN:prefix names have the string at +0x864 populated
Game crashes on event trigger
- Most common: calling convention mismatch. Verify
DispatchCollisionEventsuses__thiscall(ECX = this, callee cleans 8 bytes). Your hook must match. - Stack corruption: ensure your hook function doesn't push extra args or use wrong
RET N - Writing to invalid ball offset: always
IsBadReadPtrbefore writing - Calling game functions with wrong calling convention:
Ball_DizzyImmunityis__thiscall,Sound_Play3Dis__cdecl
Event fires but no effect
- The event name string might not match exactly. Use
_stricmpfor case-insensitive exact match, or_strnicmpfor prefix match - The event might be firing on the wrong ball (AI ball instead of player). Check
ball+0x18to verify player index - Sound pointers might be null — verify
app+0x49Cis non-zero before callingSound_Play3D
Visual verification on Wine/llvmpipe
- Game renders black on llvmpipe — visual testing is not possible on Linux
- Use
MessageBoxApopups orOutputDebugStringAlogging for verification - Full visual/gameplay testing requires real Windows hardware
🔗 Related Documents
Custom N
Custom N: Event Objects
Documentation of how N: (and E:, DN:) event objects work inside Hamsterball's collision system, and how to create new ones for mods.
Table of Contents
- Overview
- Event Name Prefixes
- How Event Names Are Stored
- The Collision Dispatch Chain
- Complete Event Catalog
- Collision Entry Struct Layout
- How to Create a Custom N: Object
- Reference: Key Addresses
1. Overview
Hamsterball's MESHWORLD level files contain geometry (triangles) organized in an octree. Each piece of geometry can carry a string name — if that name starts with N:, E:, or DN:, the engine treats it as an event trigger. When the ball's physics body collides with that geometry, the engine reads the string and dispatches to the appropriate handler function.
This means N: events are not objects in the traditional sense (they have no update loop, no vtable, no spawning). They are collision geometry with a tagged name. The "object" is purely the string name attached to a mesh strip in Section 6 of the MESHWORLD file.
2. Event Name Prefixes
| Prefix | Meaning | Dispatch Path |
|---|---|---|
N: |
Natural/environmental event | DispatchCollisionEvents (base) |
E: |
Effect/interaction event | Level/Arena handler → DispatchCollisionEvents |
DN: |
Delayed natural event | Custom vtable override (e.g. SinkPlatformArenaCollisionEvents) → DispatchCollisionEvents |
| (none) | Bare-name event | DispatchCollisionEvents (checked at name+2 offset) |
(NOCOLLIDE) |
Suffix in name | Skips collision geometry creation entirely — visual only |
The (NOCOLLIDE) suffix can be appended to any name (e.g. T:NEONARROW(NOCOLLIDE)) to tell the engine to skip building collision triangles for that mesh. The geometry renders but the ball passes through.
3. How Event Names Are Stored
3.1 MESHWORLD File (Section 6 — Octree Geometry)
In the meshworld binary format, Section 6 is a recursive octree. Each leaf node contains geometry entries, and each geometry entry has a string name. When the engine loads a level, it reads these strings and stores them with the collision mesh buffers.
3.2 In Memory — Level_LoadMeshes (0x465860)
When the level loads, Level_LoadMeshes iterates every collision geometry entry from the source MeshWorld:
// Simplified from decompiled Level_LoadMeshes (0x465860)
for each geometry entry (iVar14) in sourceMesh:
// Check for (NOCOLLIDE) — skip if present
if (entry->name != NULL && strstr(entry->name, "(NOCOLLIDE)") != NULL)
continue; // skip — visual only, no collision
// Allocate a new collision mesh buffer (0x874 bytes)
meshBuf = CreateMeshBuffer(operator_new(0x874));
// Copy the event name string
if (entry->name != NULL):
meshBuf[0x219] = _strdup(entry->name); // +0x864 = event name string ptr
// Set flags based on prefix
if (strnicmp(entry->name, "N:", 2) == 0)
meshBuf[0x85D] = 1; // natural event flag
if (strnicmp(entry->name, "E:", 2) == 0):
meshBuf[0x85D] = 1; // event flag
meshBuf[0x863] = 1; // effect flag (triggers Level/Arena handler)
3.3 Collision Mesh Buffer Layout (0x874 bytes)
| Offset (hex) | Offset (dec) | Type | Description |
|---|---|---|---|
| +0x00 | 0 | vtable* | Mesh buffer vtable (0x4D8E70) |
| +0x04 | 4 | void* | Unused / zero |
| +0x0C | 12 | AthenaList | Triangle list (each 0x60 bytes) |
| +0x21C | 540 | int | Counter |
| +0x85D | 2141 | byte | Is event flag (N: or E: prefix) |
| +0x85E | 2142 | byte | Flag (set to 0) |
| +0x85F | 2143 | byte | Flag (set to 0) |
| +0x861 | 2145 | byte | Flag (set to 0) |
| +0x862 | 2146 | byte | Flag (set to 0) |
| +0x863 | 2147 | byte | Is effect flag (E: prefix only) |
| +0x864 | 2148 | char* | Event name string (the N:/E: name) |
The +0x864 field is the critical one — this is the string pointer that all collision dispatch functions read to determine which event to fire.
4. The Collision Dispatch Chain
4.1 Physics → Collision → Event Dispatch
Ball_FallUpdate (0x408830)
→ Ball_AdvancePositionOrCollision (0x4564C0)
→ Collision_TraverseSpatialTree (0x465EF0) — finds colliding triangles
→ Ball_FallUpdate builds collision entry list at physics+0x848
→ Board vtable[0x1D] (+0x74) = collision dispatch callback
4.2 Per-Board Collision Handlers (NOT "Level vs Arena")
IMPORTANT: There is no single "TowerCollisionEvents" or "ExpertCollisionEvents" — these were incorrect Ghidra labels. The truth is that almost every board type overrides vtable[0x1D] with its own unique collision handler. Each handler processes board-specific events, then falls through to DispatchCollisionEvents (0x40C5D0) as the shared base.
The base Scene vtable (0x4D0260) sets vtable[0x1D] = DispatchCollisionEvents directly. Only the WarmUp board uses this default. Every other board overrides it.
Arena boards (Rumble) have their OWN separate vtables and collision handlers, distinct from their race-mode counterparts. Every arena handler includes DN:SINKPLATFORM handling (some via a shared SinkPlatformArenaCollisionEvents at 0x413BD0).
Race Boards
| Board | vtable[0x1D] Address | Handler | Events Handled Before Base |
|---|---|---|---|
| WarmUp | 0x0040C5D0 | DispatchCollisionEvents (no override) | (none — uses base directly) |
| Intermediate | 0x0040D340 | IntermediateCollisionEvents | N:BRIDGE |
| Dizzy | 0x0040D500 | DizzyCollisionEvents | N:WATERWHEEL, N:WHEELEMBED, N:SWIRL |
| Tower | 0x0040DCD0 | TowerCollisionEvents | E:CATAPULTBOTTOM, E:OPENSESAM, N:TRAPDOOR, E:BITE, E:MACETRIGGER, N:MACE |
| Expert | 0x0040E6A0 | ExpertCollisionEvents | E:CALLHAMMER, E:HAMMERCHASE, E:ALERTSAW1, E:ALERTSAW2, E:ACTIVATESAW1, E:ACTIVATESAW2, E:ALERTJUDGES, E:SCORE, E:JUMP, E:BELL |
| Odd | 0x0040ED30 | OddCollisionEvents | E:GRAVITY, N:JUMPFIRST, N:JUMPSECOND, E:SHRINK, E:GROWSOUND, E:GROW, E:DROPLIFT, E:PIPERANDOM, E:LIMIT, E:LIMITX, E:LIMITZ, E:LIMITPIPE1, E:LIMITPIPE2, E:SWALLOW |
| Beginner | 0x004111E0 | BeginnerCollisionEvents | N:BUMPER |
| Master (Arena) | 0x00412850 | MasterCollisionEvents | N:SPINNER, N:BUMPER, E:LAUNCH, E:CALLHAMMER, E:HAMMERCHASE, E:CATAPULTBOTTOM |
| Sky/Neon | 0x00410D00 | NeonCollisionEvents | E:PEGS, E:TRAPPOP, E:NOPEGS, E:HEATON, E:HEATOFF, E:LIMIT |
| Toob | 0x00410020 | ToobCollisionEvents | E:ALERTSAW2, E:BRANCH(A/B), N:SPINNY, N:SAWTEETH, N:BUMPER |
| Up | 0x004119B0 | UpCollisionEvents | E:HELPINERTIA, E:UNHELPINERTIA, E:VACPOPOUT, N:SPEEDCYLINDER, N:EXTRATIME |
| Wobbly | 0x0040F9A0 | WobblyCollisionEvents | N:SQUAREWOBBLY, N:WAVY |
| Glass | 0x00417EB0 | GlassCollisionEvents | N:GLASS, DN:SINKPLATFORM |
| Impossible | 0x00418360 | ImpossibleCollisionEvents | N:BOUNCE, N:ONROTATOR, N:ONGEAR, E:HELPINERTIA, E:UNHELPINERTIA |
Arena (Rumble) Boards
| Arena Board | vtable Addr | vtable[0x1D] Addr | Handler | Events Handled Before Base |
|---|---|---|---|---|
| Beginner Arena | 0x004D14F0 | 0x00413DF0 | BeginnerArenaCollisionEvents | N:BUMPER, DN:SINKPLATFORM |
| Intermediate Arena | 0x004D15C0 | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM |
| Dizzy Arena | 0x004D1680 | 0x00414350 | DizzyArenaCollisionEvents | N:SWIRL, DN:SINKPLATFORM |
| Tower Arena | 0x004D1740 | 0x00414570 | TowerArenaCollisionEvents | E:CATAPULTBOTTOM, DN:SINKPLATFORM |
| Up Arena | 0x004D17F8 | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM |
| Odd Arena | 0x004D1980 | 0x00414DA0 | OddArenaCollisionEvents | E:GRAVITY(TYPE), DN:SINKPLATFORM |
| Expert Arena | 0x004D18C8 | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM |
| Toob Arena | 0x004D1A40 | 0x00415010 | ToobArenaCollisionEvents | N:BUMPER, DN:SINKPLATFORM |
| Wobbly Arena | 0x004D1B18 | 0x00415540 | WobblyArenaCollisionEvents | N:SQUAREWOBBLY, DN:SINKPLATFORM |
| Sky/Neon Arena | 0x004D1BD8 | 0x00413BD0 | SinkPlatformArenaCollisionEvents | DN:SINKPLATFORM |
| Warmup Arena | 0x004D1C80 | 0x00416140 | WarmupArenaCollisionEvents | E:LAUNCH, DN:SINKPLATFORM |
| Impossible Arena | 0x004D2298 | 0x00418600 | ImpossibleArenaCollisionEvents | N:BOUNCE, DN:SINKPLATFORM |
| Master Arena | 0x004D12B0 | 0x00412850 | MasterCollisionEvents | N:SPINNER, N:BUMPER, E:LAUNCH, E:CALLHAMMER, E:HAMMERCHASE, E:CATAPULTBOTTOM |
Key observation: The Intermediate, Up, Expert, and Sky/Neon arenas share the same minimal SinkPlatformArenaCollisionEvents (0x413BD0) handler — it only processes DN:SINKPLATFORM then delegates to the base. The Master Arena reuses the exact same MasterCollisionEvents handler as the Master race board. All other arenas have their own unique handlers.
Dispatch chain for any collision event:
Board vtable[0x1D] (board-specific handler)
├─ Process board-specific events (if event name matches)
│ └─ Return early (for some events) OR fall through
└─ DispatchCollisionEvents (0x40C5D0) — shared base handler
└─ Process universal events (N:GOAL, N:TARPIT, N:WATER, E:JUMP, etc.)
Note: Some board handlers return early without calling DispatchCollisionEvents for certain events (e.g., Intermediate returns early on N:BRIDGE, Dizzy returns early on N:WATERWHEEL/N:WHEELEMBED/N:SWIRL). Most fall through to the base.
4.3 How the Dispatch Reads the Event Name
All handlers read the event name the same way:
// param_1 = ball (int*)
// param_2 = collision entry (int*), where:
// param_2[0] = type/source reference
// param_2[1] = collision object pointer
//
// Event name string = *(char**)(param_2[1] + 0x864)
char *eventName = *(char **)(param_2[1] + 0x864);
// Then compared via __stricmp / __strnicmp:
if (__stricmp(eventName, "N:GOAL") == 0) { ... }
if (__strnicmp(eventName, "N:SECRET", 8) == 0) { ... }
5. Complete Event Catalog
Events are split into two categories:
- Universal events — handled by
DispatchCollisionEvents(0x40C5D0), the shared base that ALL boards call - Board-specific events — handled by each board's own vtable[0x1D] override BEFORE calling DispatchCollisionEvents
5.1 Universal Events — DispatchCollisionEvents (0x40C5D0)
These events fire on ALL board types because every board handler calls DispatchCollisionEvents at the end.
N: Events (Universal)
| Event Name | What It Does | Ball Fields Affected |
|---|---|---|
N:SECRET |
Calls Rotator_MarkTriggered(rotator_ptr) — marks a secret as found |
Reads *param_2 + 0x47C (rotator) |
N:UNLOCKSECRET |
Calls CheckArenaUnlock(this) — unlocks arena content |
Uses board (this) |
N:NOCONTROL |
Sets impact timer = 10 (ball loses control briefly) | ball[0x202] = 10 |
N:WATER |
Sets water flag + timer = 10 (water physics effect) | ball+0x2D5 = 1, ball[0xB6] = 10 |
N:TARPIT |
Plays tar sound, sets tar flag, disables dizzy | ball+0x2CC = 1, ball[0x1DA] = 0, ball[0xB4] = pos, plays sound at board+0x484 |
N:GOAL |
Finishes the race for the current player | Sets App+0x5D6 (finished flag), plays "Goal!" music, copies camera angles, sets App+0x5F0 |
N:MOUSETRAP |
Deflects ball trajectory, plays rotator collision sound, adds to rotator list | Scales trajectory vector by _DAT_004CF370, sets Y=15.0, iterates rotator list |
E: Events (Universal)
| Event Name | What It Does | Key Details |
|---|---|---|
E:NODIZZY<TIME>N</TIME> |
Grants dizzy immunity N via Ball_DizzyImmunity |
Parses XML tags with MWParser_ReadTag |
E:SAFESWITCH |
Copies parenthesized data to ball+0xC2C |
strchr for ( char, copies string |
E:LIMIT |
Tracks arena completions per player | Sets ball+0x2E9=1, increments counters at board+0x47B4/B8/BC/C0 |
E:BREAK |
Calls ball vtable[0x20] callback | (**(code**)(*ball + 0x20))() |
E:JUMP |
Jump pad: 3D sound + force + impact=10 + score 200 | ball[0x1F7]=10, ball[0xA7]=0x3B03126F, ball[0xA8]=1, ball[0x202]=10 |
E:ACTION(ONCE)(SCORE) |
Score event with optional once-only tracking | Parses XML: ONCE=TRUE → AthenaList_Append(ball+0xCB, obj), SCORE=N → adds to App+0x5E4 + pIdx*0xA0 |
E:TRAJECTORY(X,Y,Z) |
Sets ball trajectory vector | Writes to physics+0xCA4/CA8/CAC |
Bare-Name Events (Universal — checked at name+2)
These events are checked by skipping the first 2 characters of the name (comparing at name + 2), meaning they work with or without a prefix:
| Event Name | What It Does | Cooldown Field |
|---|---|---|
DROPIN |
Sound + score +200 | ball[0x1F2] (0x32 = 50 frames) |
PIPEBONK |
Random sound + score +100 | ball[500] (0x32) |
POPOUT |
Sound + score +100 | ball[499] (0x32) |
| (4th, at PTR_DAT_004cf80c) | Sound | ball[0x1F5] (0x32) |
5.2 Board-Specific Events — Per-Board vtable[0x1D] Handlers
These events are ONLY processed by specific board types. If the ball touches geometry with one of these names on a different board, it will be ignored (the board handler won't match it, and DispatchCollisionEvents doesn't know about it either).
Tower (0x40DCD0)
| Event Name | What It Does |
|---|---|
E:CATAPULTBOTTOM |
Sets impact=1000, iterates catapult list at board+0x43B8, calls Catapult_Launch |
E:OPENSESAM |
Opens trapdoor — iterates trapdoor list at board+0x4BE8, calls Trapdoor_Open |
N:TRAPDOOR |
Activates trapdoor — iterates trapdoor list, calls Trapdoor_Activate |
E:BITE |
Sets damage = 25.0 at board+0x43A0, clears board+0x43A8 |
E:MACETRIGGER |
Activates all maces — sets mace+0x10F0 = 1 |
N:MACE |
Ball bounce callback — calls ball vtable[0x20] if mace conditions met |
Expert (0x40E6A0)
| Event Name | What It Does |
|---|---|
E:CALLHAMMER |
Creates BONK popup (difficulty-gated: App+0x23C != 0) |
E:HAMMERCHASE |
Starts hammer chase (difficulty-gated) |
E:ALERTSAW1 / E:ALERTSAW2 |
Alerts saw blade (difficulty-gated) |
E:ACTIVATESAW1 / E:ACTIVATESAW2 |
Activates saw blade (difficulty-gated) |
E:ALERTJUDGES |
Resets all judges via Judge_Reset |
E:SCORE (prefix match) |
Sets score display time via ScoreDisplay_SetTime |
E:JUMP |
Duplicate of base E:JUMP (Expert has its own copy) |
E:BELL (prefix match) |
Activates bell, awards 500 bonus time + creates ScoreObject |
Master / Arena (0x00412850 — MasterCollisionEvents)
| Event Name | What It Does |
|---|---|
N:SPINNER |
Calls Rotator_AddObject — attaches ball to spinner rotator |
N:BUMPER |
Bumper physics: plays sound, scales/reverses velocity, sets board flag at +0x53FC + bumperIndex*4 |
E:LAUNCH |
Launch pad: looks up "LAUNCHPOINT" position, sets upward trajectory (Y=16.0), impact=50, creates ArenaScoreParticle explosion particles |
E:CALLHAMMER |
Creates BONK popup (difficulty-gated) |
E:HAMMERCHASE |
Starts hammer chase (difficulty-gated) |
E:CATAPULTBOTTOM |
Same as Tower — iterates catapult list at board+0x584C, calls Catapult_Launch |
Dizzy (0x0040D500)
| Event Name | What It Does |
|---|---|
N:WATERWHEEL |
Sets ball+0x1DE = 1 (water wheel flag), returns early — does NOT call DispatchCollisionEvents |
N:WHEELEMBED |
Embeds ball in wheel: computes relative position to wheel center (board+0x4BB0), applies rotation transform, sets ball+0x30F = 1 (teleport flag), writes new position to ball+0x310/311/312, sets impact=50. Returns early. |
N:SWIRL |
Sets ball+0x779 = 1 (swirl flag), returns early |
Intermediate (0x0040D340)
| Event Name | What It Does |
|---|---|
N:BRIDGE |
Checks if board+0x4384 == 3 (bridge state). If so, sets ball+0x1DE = 1 and returns early. Otherwise falls through to DispatchCollisionEvents. |
Odd (0x0040ED30)
| Event Name | What It Does |
|---|---|
E:GRAVITY(TYPE) |
Changes gravity type: NORMAL → Ball_ResetCollisionMesh, X → Ball_SetTiltedGravity, Z → Ball_SetFlatGravity. Parses XML tags. |
N:JUMPFIRST |
Teleports ball to "JUMPPIPE1" position, sets upward trajectory (Y=16.0) |
N:JUMPSECOND |
Teleports ball to "JUMPPIPE2" position, sets upward trajectory (Y=16.0) |
E:SHRINK |
Calls Ball_Shrink (shrinks ball), teleports to "SHRINKCENTER", sets downward trajectory (Y=-1.0) |
E:GROWSOUND |
Plays grow sound (cooldown: ball[0x1FE], 100 frames) |
E:GROW |
Calls Ball_Grow (restores ball size) |
E:DROPLIFT |
Calls Stands_PlayBreakSound on mesh at board+0x436C |
E:PIPERANDOM |
Randomly teleports ball to "PIPERANDOM1" or "PIPERANDOM2" position, zeroes velocity, plays sound |
E:LIMIT |
If ball[0x1D2] == 0: sets ball+0x1DA = 0, ball+0x2E9 = 1 (qualifies for this limit gate) |
E:LIMITX |
Same as LIMIT but checks ball[0x1D2] == 1 |
E:LIMITZ |
Same as LIMIT but checks ball[0x1D2] == 2 |
E:LIMITPIPE1 |
If ball[0x1] != 0 (pipe 1 flag): qualifies for limit |
E:LIMITPIPE2 |
If ball[0x5] != 0 (pipe 2 flag): qualifies for limit |
E:SWALLOW |
Sets ball+0xBA = 1 (swallow/pipe entry flag) |
Beginner (0x004111E0)
| Event Name | What It Does |
|---|---|
N:BUMPER |
Bumper physics: plays sound, scales/reverses velocity (similar to Master's N:BUMPER), sets board flag at +0x6428 + bumperIndex*4 |
Sky / Neon Race (0x00410D00 — NeonCollisionEvents)
| Event Name | What It Does |
|---|---|
E:PEGS |
Increments peg counter at board+0x47F4, sets ball[0x1E2] = 1 (peg hit flag) |
E:TRAPPOP |
Plays rotator sound (difficulty-gated: App+0x23C != 0) |
E:NOPEGS |
Decrements peg counter at board+0x47F4, sets ball[0x1E3] = 1 |
E:HEATON |
Adds ball to pendulum list via Pendulum_AddIndex (difficulty-gated) |
E:HEATOFF |
Removes ball from pendulum list (difficulty-gated) |
E:LIMIT |
Removes ball from pendulum list (difficulty-gated, same as HEATOFF) |
Toob (0x00410020)
| Event Name | What It Does |
|---|---|
E:ALERTSAW2 |
Alerts saw at board+0x4384 (difficulty-gated) |
E:BRANCH(A) / E:BRANCH(B) |
Branching pipe: looks up numbered POS/VECTOR pairs from hash table, randomly selects one, teleports ball with scaled trajectory. (A) = normal, (B) = double velocity. |
N:SPINNY |
Calls Rotator_AddBall — awards score from rotator |
N:SAWTEETH |
Deflects ball: reads position from rotator+0x1100, normalizes and scales by 3.0, calls Ball_ApplyTrajectory. Cooldown: ball[0x1F7]. |
N:BUMPER |
Bumper physics: plays sound, scales/reverses velocity, sets board flag at +0x6448 + bumperIndex*4 |
Up (0x004119B0)
| Event Name | What It Does |
|---|---|
E:HELPINERTIA |
Sets ball[0xA9] = 2.5 (reduces inertia — easier to control) |
E:UNHELPINERTIA |
Sets ball[0xA9] = 5.0 (restores normal inertia) |
E:VACPOPOUT |
Sets ball[0xA1] = 20.0 (vacuum popout force), plays 3D sound |
N:SPEEDCYLINDER |
Calls Pendulum_PlayCollisionSound — speed boost sound from rotator |
N:EXTRATIME |
Awards 500 bonus time: creates ScoreObject "EXTRA TIME:", calls Timer_Decrement, adds to board's score list at +0x8B8. Checks rotator+0x10E4 (once-only flag). |
Wobbly (0x0040F9A0)
| Event Name | What It Does |
|---|---|
N:SQUAREWOBBLY |
Calls Stands_AddObject — adds ball to wobbly platform physics |
N:WAVY |
Calls Blockdawg_AddObject — adds ball to wavy/blockdawg physics |
Glass (0x00417EB0)
| Event Name | What It Does |
|---|---|
N:GLASS |
Sets ball[0x317] = 0xF (glass break counter — 15 hits to break) |
DN:SINKPLATFORM |
Calls Scene_StartCountdown to sink the platform the ball is standing on |
Impossible (0x00418360)
Handles events for the Impossible Race (Race of Ages) — the gear/rotator level.
| Event Name | What It Does |
|---|---|
N:BOUNCE |
Bounces ball off gears: doubles velocity, clamps to min 1.25 / max 3.0 via normalize-and-scale. Gated on ball+0x1DA != 0 (active flag). |
N:ONROTATOR |
Calls Rotator_AddBall — registers ball on the rotator's tracking list with a 10-frame tick counter. See Rotator System below. |
N:ONGEAR |
Calls Catapult_AddObjectConditional — same pattern as Rotator_AddBall but on a Catapult object (guarded by catapult+0x1510 != 0). Registers ball on the gear's tracking list for rotational movement. |
E:HELPINERTIA |
Sets ball[0xA9] = 2.5 (reduces inertia — easier to control on gears) |
E:UNHELPINERTIA |
Sets ball[0xA9] = 5.0 (restores normal inertia) |
The Rotator System (Gears, Swirls, Spinny Objects)
The rotator system is how Hamsterball makes spinning objects physically carry the ball. It involves two functions working together: one registers the ball on contact, the other applies rotation every frame.
Rotator_AddBall (0x43B6F0 — formerly misnamed Rotator_AddBall)
This function does NOT set a score. It registers the ball on a rotator's ball-tracking list.
Rotator_AddBall(Scene* scene, Ball* ball):
// Search existing list for this ball
for each entry in scene->rotatorList (AthenaList at scene+0x10F0):
if entry->ball_ptr == ball:
entry->tick_counter = 10 // RESET to 10 — ball already tracked
return
// Ball not in list — add new entry
entry = malloc(8) // 8-byte struct: [ball_ptr, tick_counter]
entry->ball_ptr = ball
entry->tick_counter = 10
AthenaList_Append(scene->rotatorList, entry)
Critical behavior: The counter resets to 10 every frame the ball is touching the rotator's collision surface. Since Ball_FallUpdate fires N:ONROTATOR/N:SPINNY/N:SWIRL on every frame of contact, Rotator_AddBall finds the ball already in the list and just resets the counter. The 10-frame countdown only starts ticking down after the ball leaves the rotator surface — it's a grace/release period, not a carry limit.
Catapult_Update (0x43E600 — shared update for Catapult AND Rotator objects)
Each frame, this function iterates the ball-tracking list and applies the object's rotation matrix:
Catapult_Update(Catapult* this):
// Build rotation matrix from this->rotation params (this+0x439..0x43C)
Gfx_ScaleZ(this->rotSpeedZ)
Gfx_ScaleX(this->rotSpeedX)
Gfx_ScaleY(this->rotAngle)
Gfx_ScaleY(this->rotSpeedAngle)
for each entry in this->ballList (AthenaList at this+0x43E):
entry->tick_counter -= 1
if tick_counter < 1:
free(entry) // Remove ball from tracking
continue
// Get ball position relative to rotator center
relX = ball->posX (ball+0x164) - this->centerX (this+0x436)
relY = ball->posY (ball+0x168) - this->centerY (this+0x437)
relZ = ball->posZ (ball+0x16C) - this->centerZ (this+0x438)
// Apply rotation matrix to relative position
newX = mat[0]*relX + mat[1]*relY + mat[2]*relZ + mat[3]
newY = mat[4]*relX + mat[5]*relY + mat[6]*relZ + mat[7]
// Write rotated position back to ball
ball->posX = newX + this->centerX
ball->posY = newY + this->centerY
ball->posZ = newZ + this->centerZ
// Also rotate ball's velocity vector (ball+0xCA4/+0xCA8/+0xCAC)
// using the same rotation matrix
ball->velX = mat[0]*velX + mat[1]*velY + mat[2]*velZ + mat[3]
ball->velY = mat[4]*velX + mat[5]*velY + mat[6]*velZ + mat[7]
ball->velZ = ...
Struct layout (Catapult/Rotator object):
| Offset | Type | Field |
|---|---|---|
| +0x436 | float | centerX (rotator pivot X) |
| +0x437 | float | centerY (rotator pivot Y) |
| +0x438 | float | centerZ (rotator pivot Z) |
| +0x439 | float | rotSpeedZ |
| +0x43A | float | rotSpeedX |
| +0x43B | float | rotSpeedAngle |
| +0x43C | float | rotAngle (accumulated) |
| +0x43E | AthenaList | ballList (tracked balls with tick counters) |
| +0x10F0 | AthenaList | rotatorList (Scene-level, used by Rotator_AddBall) |
| +0x10F8 | AthenaList | catapultBallList (used by Catapult_AddObjectConditional) |
| +0x1510 | byte | active flag (Catapult_AddObjectConditional guard) |
Called from 3 collision handlers:
| Collision Handler | Event | Level |
|---|---|---|
ImpossibleCollisionEvents (0x418360) |
N:ONROTATOR |
Impossible race (gears) |
ToobCollisionEvents (0x410020) |
N:SPINNY |
Toob race |
DizzyArenaCollisionEvents (0x414350) |
N:SWIRL |
Dizzy arena |
6. Collision Entry Struct Layout
When Ball_FallUpdate processes collisions, it builds a collision entry struct that gets passed to the dispatch functions.
6.1 Collision Entry (8 bytes — on stack)
struct CollisionEntry {
int type_or_source_ref; // +0x00: reference to source (e.g. rotator ptr)
void* collision_obj; // +0x04: pointer to collision mesh buffer (0x874 bytes)
};
The dispatch functions access the event name as:
char *eventName = *(char **)(collision_entry.collision_obj + 0x864);
6.2 Collision Mesh Buffer (0x874 bytes — heap allocated)
struct CollisionMeshBuffer {
void* vtable; // +0x00: 0x4D8E70
void* unused; // +0x04
AthenaList triangles; // +0x0C: list of CollisionFace (0x60 bytes each)
// ... (collision geometry data) ...
byte is_event; // +0x85D: 1 if name starts with N: or E:
byte is_effect; // +0x863: 1 if name starts with E:
char* event_name; // +0x864: the event string (e.g. "N:TARPIT")
};
7. How to Create a Custom N: Object
There are four approaches, ranging from simplest to most complex.
Approach A: MESHWORLD Level Editing (No Code)
Best for: Adding existing event types to new locations in a level.
Edit a .MESHWORLD file's Section 6 octree to add geometry with an event name. The engine automatically processes it — no code changes needed.
- Create a mesh with triangles at the desired location
- Name the mesh entry
N:TARPIT,E:JUMP,N:GOAL, etc. - Delete all
.cachedfiles so the game re-reads the.MESHWORLD - The engine will build collision geometry for the named mesh and dispatch the event when the ball touches it
Limitation: You can only use event names that already exist in the engine. The engine ignores unrecognized names (they get collision geometry but no event fires).
See: reference/raptisoft-exporter/ for the meshworld binary format spec.
Approach B: Fake Collision Entry (CEA Script or DLL)
Best for: Triggering existing event logic at arbitrary positions/times without level geometry.
Construct a fake collision entry struct in memory, fill in the event name, and call DispatchCollisionEvents directly. This is the GluebieSpawn pattern — used to trigger N:TARPIT effects when the ball is near Gluebie objects that have no collision geometry of their own.
CEA Script Pattern (from GluebieSpawn.CEA):
// Allocate fake structures
alloc(fake_coll_obj, 0x868) // collision mesh buffer (only +0x864 matters)
alloc(fake_coll_entry, 8) // collision entry struct
alloc(tarpit_str, 16)
// Write event name string
tarpit_str:
db 'N:TARPIT',00
// Set up fake collision object — only +0x864 is read
fake_coll_obj + 0x864:
dd tarpit_str
// Set up fake collision entry: [source_ref, collision_obj_ptr]
fake_coll_entry:
dd 0 // type/source (0 = generic)
dd fake_coll_obj // pointer to fake collision object
// Call DispatchCollisionEvents (thiscall: ECX=board, push entry, push ball)
// DispatchCollisionEvents(board, ball, entry):
// ECX = board (from ball+0x14)
// push fake_coll_entry
// push ball
// call 0040C5D0 // __thiscall, callee cleans 8 bytes (RET 0x8)
DLL (C) Pattern:
// Build a fake collision entry to trigger N:TARPIT
typedef struct {
int source_ref;
void* collision_obj;
} CollisionEntry;
// Minimal collision object — only +0x864 is read
static char fake_coll_obj[0x868];
static const char* tarpit_name = "N:TARPIT";
*(const char**)(fake_coll_obj + 0x864) = tarpit_name;
CollisionEntry entry;
entry.source_ref = 0;
entry.collision_obj = fake_coll_obj;
// Call DispatchCollisionEvents (thiscall)
// ECX = board, params = (ball, &entry)
typedef void (__thiscall *DispatchFn)(void* board, int* ball, CollisionEntry* entry);
DispatchFn DispatchCollisionEvents = (DispatchFn)(0x400000 + 0xC5D0);
void* board = *(void**)(ball + 0x14); // ball+0x14 = board
DispatchCollisionEvents(board, ball, &entry);
// Note: __thiscall — callee cleans 8 bytes from stack (RET 0x8)
Important calling convention note: DispatchCollisionEvents is __thiscall with RET 0x8 — the callee cleans up 8 bytes (the two pushed parameters). When calling from inline assembly, do NOT add esp, 8 after the call. When calling from C with a __thiscall typedef, the compiler handles this automatically.
Approach C: Hook DispatchCollisionEvents (DLL)
Best for: Adding entirely new event names with custom logic.
Hook DispatchCollisionEvents (0x40C5D0) to intercept the event name string before the original function processes it. If the name matches your custom event, execute your logic. Otherwise, fall through to the original.
// Pseudo-code for a DLL hook
void __fastcall hooked_DispatchCollisionEvents(void* board, void* edx,
int* ball, int* entry) {
char* eventName = *(char**)(entry[1] + 0x864);
if (eventName) {
// Custom event handling
if (_stricmp(eventName, "N:SPEEDBOOST") == 0) {
// Your custom logic: add velocity to ball
*(float*)(ball + 0x170) += 50.0f; // velocity X
return; // don't call original
}
if (_stricmp(eventName, "N:GRAVITYFLIP") == 0) {
// Reverse gravity
*(float*)(ball + 0xC94) *= -1.0f;
return;
}
}
// Fall through to original DispatchCollisionEvents
original_DispatchCollisionEvents(board, ball, entry);
}
The event name would be placed in a custom MESHWORLD file's Section 6 geometry (Approach A), or triggered via a fake collision entry (Approach B).
Key advantage: You can create completely new event names that don't exist in the original engine. The engine will build collision geometry for any N:/E: prefixed name (it sets the flags but doesn't validate the name), and your hook can intercept it.
Approach D: Vtable Override (DLL)
Best for: Adding per-object custom collision handling (like SinkPlatform's DN: pattern).
Override the scene's vtable[0x1D] (+0x74) to point to your own collision handler. This is how SinkPlatformArenaCollisionEvents works — it checks for DN:SINKPLATFORM before calling DispatchCollisionEvents.
// Custom collision handler (same signature as DispatchCollisionEvents)
void __fastcall MyCollisionHandler(void* board, void* edx,
int* ball, int* entry) {
char* eventName = *(char**)(entry[1] + 0x864);
if (eventName && _stricmp(eventName, "N:MYCUSTOM") == 0) {
// Custom logic here
DoSomething(ball, board);
}
// Always fall through to base dispatch
DispatchCollisionEvents(board, ball, entry);
}
// Install: patch board vtable[0x1D] to point to MyCollisionHandler
DWORD oldProtect;
void** vtable = *(void***)board; // board's vtable
VirtualProtect(&vtable[0x1D], 4, PAGE_EXECUTE_READWRITE, &oldProtect);
vtable[0x1D] = (void*)MyCollisionHandler;
VirtualProtect(&vtable[0x1D], 4, oldProtect, &oldProtect);
8. Reference: Key Addresses
Functions
| Address | Name | Description |
|---|---|---|
| 0x0040C5D0 | DispatchCollisionEvents | Base collision event handler (universal N:/E:/bare events) |
| Race Board Handlers | ||
| 0x0040D340 | IntermediateCollisionEvents | Intermediate race board (N:BRIDGE) |
| 0x0040D500 | DizzyCollisionEvents | Dizzy race board (N:WATERWHEEL, N:WHEELEMBED, N:SWIRL) |
| 0x0040DCD0 | TowerCollisionEvents | Tower race board (catapult, trapdoor, mace, bite) |
| 0x0040E6A0 | ExpertCollisionEvents | Expert race board (hammer, saw, judge, bell, score) |
| 0x0040ED30 | OddCollisionEvents | odd race board (gravity, pipes, shrink/grow, limits) |
| 0x0040F9A0 | WobblyCollisionEvents | Wobbly race board (N:SQUAREWOBBLY, N:WAVY) |
| 0x00410020 | ToobCollisionEvents | Toob race board (branch pipes, spinny, sawteeth, bumper) |
| 0x00410D00 | NeonCollisionEvents | Sky/Neon race board (pegs, heat, limit) |
| 0x004111E0 | BeginnerCollisionEvents | Beginner race board (N:BUMPER) |
| 0x004119B0 | UpCollisionEvents | Up race board (inertia, vacpopout, speed cylinder, extra time) |
| 0x00412850 | MasterCollisionEvents | Master race board AND Master Arena (spinner, bumper, launch, catapult) |
| 0x00417EB0 | GlassCollisionEvents | Glass race board (N:GLASS, DN:SINKPLATFORM) |
| 0x00418360 | ImpossibleCollisionEvents | Impossible race board (N:BOUNCE, N:ONROTATOR, N:ONGEAR, E:HELPINERTIA, E:UNHELPINERTIA) |
| Arena (Rumble) Board Handlers | ||
| 0x00413BD0 | SinkPlatformArenaCollisionEvents | Shared handler for Intermediate/Up/Expert/Sky arenas (DN:SINKPLATFORM only) |
| 0x00413DF0 | BeginnerArenaCollisionEvents | Beginner arena (N:BUMPER, DN:SINKPLATFORM) |
| 0x00414350 | DizzyArenaCollisionEvents | Dizzy arena (N:SWIRL, DN:SINKPLATFORM) |
| 0x00414570 | TowerArenaCollisionEvents | Tower arena (E:CATAPULTBOTTOM, DN:SINKPLATFORM) |
| 0x00414DA0 | OddArenaCollisionEvents | Odd arena (E:GRAVITY, DN:SINKPLATFORM) |
| 0x00415010 | ToobArenaCollisionEvents | Toob arena (N:BUMPER, DN:SINKPLATFORM) |
| 0x00415540 | WobblyArenaCollisionEvents | Wobbly arena (N:SQUAREWOBBLY, DN:SINKPLATFORM) |
| 0x00416140 | WarmupArenaCollisionEvents | Warmup arena (E:LAUNCH, DN:SINKPLATFORM) |
| 0x00418600 | ImpossibleArenaCollisionEvents | Impossible arena (N:BOUNCE, DN:SINKPLATFORM) |
| Other | ||
| 0x00465860 | Level_LoadMeshes | Loads collision geometry + copies event names to mesh buffers |
| 0x00458970 | CreateMeshBuffer | Allocates 0x874-byte collision mesh buffer |
| 0x00408830 | Ball_FallUpdate | Physics update — triggers collision dispatch |
| 0x00465EF0 | Collision_TraverseSpatialTree | Octree traversal for collision detection |
| 0x00453810 | AthenaList_Append | Append to AthenaList (used for collision entry lists) |
Key Offsets
| Offset | On Object | Type | Description |
|---|---|---|---|
| +0x864 | Collision Mesh Buffer | char* | Event name string (the N:/E: name) |
| +0x85D | Collision Mesh Buffer | byte | Is event flag (N: or E: prefix) |
| +0x863 | Collision Mesh Buffer | byte | Is effect flag (E: prefix only) |
| +0x74 | Board/Scene vtable | func* | vtable[0x1D] — collision dispatch callback |
| +0x14 | Ball | void* | Board pointer |
| +0x878 | Board | void* | Scene/App pointer |
| +0x164 | Ball | float | Position X |
| +0x168 | Ball | float | Position Y |
| +0x16C | Ball | float | Position Z |
| +0x170 | Ball | float | Velocity X (accumulated, cleared each frame) |
| +0x174 | Ball | float | Velocity Y |
| +0x178 | Ball | float | Velocity Z |
| +0x2CC | Ball | byte | Tar flag (set by N:TARPIT) |
| +0x2D5 | Ball | byte | Water flag (set by N:WATER) |
| +0x202 | Ball | int | Impact timer (set by N:NOCONTROL, E:JUMP, etc.) |
| +0x23C | App | int | Difficulty enum (0=Pipsqueak, 1=Normal, 2=Frenzied) |
| +0x5D6 | App | byte | Player finished flag (set by N:GOAL) |
| +0x5E4 | App | float | Score (per player, +pIdx*0xA0) |
Calling Convention
DispatchCollisionEvents is __thiscall:
- ECX = board/scene pointer (this)
- Stack param 1 = ball pointer
- Stack param 2 = collision entry pointer
- Returns with
RET 0x8(callee cleans 8 bytes)
; Assembly call pattern:
push fake_coll_entry ; param 2: collision entry
push ball ; param 1: ball
mov ecx, board ; this: board
call 0040C5D0 ; DispatchCollisionEvents
; No "add esp, 8" needed — callee cleans stack (RET 0x8)
Appendix: Existing Mod Examples
GluebieSpawn (tools/gluebie_spawn/GluebieSpawn.CEA)
Uses Approach B (fake collision entry) to trigger N:TARPIT when the ball is near Gluebie objects. The Gluebie mesh has no collision geometry, so the mod constructs a fake collision entry with N:TARPIT as the event name and calls DispatchCollisionEvents directly.
Collision Hook (tools/collision_hook/)
A read-only DLL that hooks all three dispatch functions to log every collision event to a CSV file. Useful for discovering which events fire during gameplay and verifying custom N: objects are working.
🔗 Related Documents
Custom Object Types
Custom Object Types — Modding Plan
Goal: Add custom object types to Hamsterball levels that interact with the game engine
without modifying the original EXE.
Architecture Overview
There are two injection points for custom objects:
- Ref Points (Section 1) — Game logic objects looked up by name during scene construction
- Octree Geoms (Section 6) — Collision/render geometry with named event triggers
Both are purely data-driven — the game loads them from the .MESHWORLD binary file.
No EXE modification is needed to add named objects to levels. The constraint is
that the EXE only recognizes specific name prefixes for factory dispatch and
collision events.
Strategy: Three Approaches (Escalating Complexity)
Approach A: Binary MW File Editing (Data-Only Modding)
What you can do WITHOUT touching the EXE:
-
Add new E: event zones with existing event names to any level
- Insert named collision geometry (e.g.,
E:JUMP,E:NODIZZY<TIME>300</TIME>) - The collision dispatcher (
DispatchCollisionEvents) will process anyE:/N:name it recognizes - Unrecognized names are silently ignored (no crash, no effect)
- Insert named collision geometry (e.g.,
-
Add new N: interactive collision objects with existing names
- E.g., add
N:GOALto a new location, addN:TARPITzones,N:WATERzones - These trigger existing game logic via the collision dispatch chain
- E.g., add
-
Reposition existing ref points (START, FLAG, SAFESPOT, etc.)
- Edit Section 1 coordinates to change spawn points, checkpoint locations
-
Add BADBALL enemy balls with custom parameters
<CHASE>,<HOME>,<SIZE>,<SPINDISTANCE>tags are parsed from the name string
-
Add factory objects (BRIDGE, TIPPER, BONK, CATAPULT, etc.)
- The factory dispatcher (
CreateLevelObjects) matches by prefix - Adding a
CATAPULTref point + associated mesh data creates a working catapult
- The factory dispatcher (
-
Change materials/textures on existing geometry
- Per-geom material data in Section 6 (ambient/diffuse/specular/emissive/power/texture)
Tool needed: A .MESHWORLD binary editor that can parse and re-serialize the format.
The parser in this project's docs/MESHWORLD_OBJECT_SYSTEM.md documents the full format.
Limitations:
- Cannot create genuinely NEW behavior (new event names are ignored)
- Cannot add new factory types (the prefix list is hardcoded in the EXE)
- Cannot change physics parameters (gravity, friction, etc. are in the EXE)
Approach B: DLL Proxy Hooking (Runtime Behavior Extension)
What you can do with a proxy DLL (bass.dll or d3d8.dll):
-
Hook the collision dispatch to intercept
DispatchCollisionEvents(0x40C5D0)- Intercept
collider->namebefore the EXE processes it - Recognize custom
E:CUSTOM_*names and execute custom logic - Then call the original
DispatchCollisionEventsfor standard events
- Intercept
-
Hook the factory dispatcher
CreateLevelObjects(0x4121D0)- Intercept ref point names before the EXE matches them
- Recognize custom prefixes (e.g.,
CUSTOM_BOSS,CUSTOM_TELEPORTER) - Allocate custom game objects, register them in the scene's active object list
-
Hook
Mesh_FindClosestCollision(0x465D90)- Add custom collision response for custom named objects
- Could implement warp pads, speed pads, gravity wells, etc.
-
Add per-frame update hooks via the scene update loop
- Hook
Scene_UpdateBallsAndStateto run custom object logic each frame - Access game state through known struct offsets (Scene, Ball, App)
- Hook
Implementation pattern (bass.dll proxy, proven to work for FPS mods):
// Hook DispatchCollisionEvents to intercept custom event names
typedef void (__thiscall *DispatchCollisionEvents_t)(void* this, int* ball, int* collObj);
DispatchCollisionEvents_t original_DispatchCollisionEvents = NULL;
void __thiscall Hooked_DispatchCollisionEvents(void* this, int* ball, int* collObj) {
char* eventName = *(char**)(collObj[1] + 0x864);
// Check for custom event names
if (strnicmp(eventName, "E:CUSTOM_WARP", 17) == 0) {
// Custom teleport logic: move ball to a target ref point
Vec3 target = HashTable_Lookup(scene_hashtable, "TELEPORT_TARGET");
ball->pos_x = target.x;
ball->pos_y = target.y;
ball->pos_z = target.z;
return; // Skip original handler
}
// Fall through to original handler for standard events
original_DispatchCollisionEvents(this, ball, collObj);
}
Key addresses for hooking:
DispatchCollisionEvents= 0x40C5D0 (base collision event handler)TowerCollisionEvents= 0x40DCD0 (race-specific events)ExpertCollisionEvents= 0x40E6A0 (arena-specific events)CreateLevelObjects= 0x4121D0 (factory dispatcher)CreateExpertLevelObjects= 0x40E250 (arena factory sub-dispatcher)Mesh_FindClosestCollision= 0x465D90 (collision raycast)Scene_SpawnBallsAndObjects= 0x41C5B0 (scene initialization)Scene_UpdateBallsAndState— per-frame update (call via vtable)- Image base: 0x400000 (use RVA = addr - 0x400000 for ASLR safety)
Limitations:
- Requires C/C++ compilation (MinGW cross-compile, proven workflow)
- Must handle VS2003 CRT heap isolation (use
HeapAllocwith game heap, notmalloc) - All struct offsets must be verified against the specific EXE version
- DLL proxy pattern is well-established (bass.dll proxy works, documented in skill)
Approach C: Full EXE Patching (Binary Modification)
What you can do by patching the EXE directly:
-
Add new factory dispatch entries in
CreateLevelObjects- Extend the
__strnicmpchain to recognize new prefixes - Allocate and initialize custom game object structs
- Extend the
-
Add new collision event handlers in
DispatchCollisionEvents- Extend the
__stricmpchain to handle newE:/N:names
- Extend the
-
Modify physics constants (gravity, friction, max speed, etc.)
-
Add new vtable entries for custom object update/render/dtor
-
Patch the octree loader to handle custom data sections
Implementation: Direct binary patching with hex editor or patcher script
(similar to the 8ball AI patches at 0x4083D3, 0x4085D1, 0x408598).
Limitations:
- Most invasive approach — changes the original EXE
- Must preserve code alignment and instruction boundaries
- Limited by available code cave space for new logic
- Each patch must be version-specific
Recommended Implementation Plan
Phase 1: MW File Editor Tool (Approach A)
Build a Python tool that can:
- Parse any .MESHWORLD binary file into structured data
- Edit ref points (add/remove/reposition)
- Add named collision geometry to the octree
- Modify materials and textures
- Re-serialize to valid .meshworld format
This enables immediate modding with all existing object types.
Phase 2: Custom Event DLL (Approach B)
Build a bass.dll proxy that:
- Hooks
DispatchCollisionEventsat 0x40C5D0 - Recognizes custom
E:CUSTOM_*event names - Implements a registry of custom event handlers (configurable via INI)
- Provides custom events: teleport, speed pad, gravity flip, ball size change, etc.
Phase 3: Custom Factory Objects (Approach B+)
Extend the proxy DLL to:
- Hook
CreateLevelObjectsat 0x4121D0 - Recognize custom ref point prefixes
- Allocate custom game objects with update/render/collision vtables
- Register in scene's active object list (+0x2578)
- Implement custom object types: moving platforms, collectibles, bosses
Custom Object Type Design Space
Given the engine architecture, feasible custom object types include:
| Type | Implementation | Data Required |
|---|---|---|
| Warp Pad | Hook DispatchCollisionEvents, E:CUSTOM_WARP → move ball |
Target ref point in Section 1 |
| Speed Pad | Hook DispatchCollisionEvents, E:CUSTOM_SPEED → add velocity |
Direction + magnitude in name |
| Gravity Zone | Hook DispatchCollisionEvents, E:CUSTOM_GRAVITY → change gravity |
Direction in XML tags |
| Size Changer | Hook DispatchCollisionEvents, E:CUSTOM_GROW/SHRINK → change ball radius |
New radius in name |
| Moving Platform | Hook factory, custom prefix → animate mesh | Waypoints in Section 1 |
| Collectible | Hook DispatchCollisionEvents, N:CUSTOM_COIN → score + disappear |
Score value in name |
| Custom Hazard | Hook DispatchCollisionEvents, N:CUSTOM_HAZARD → damage/respawn |
Damage value in name |
| Checkpoints++ | Hook DispatchCollisionEvents, E:CUSTOM_CHECKPOINT → track progress |
Order in name |
| Wind Zone | Hook per-frame, apply force in region | Direction + strength |
| Ice Surface | Hook per-frame, reduce friction in zone | Friction value |
Key Technical Constraints
- Image base: 0x400000. Use RVA = addr - 0x400000, then
GetModuleHandle(NULL) + RVAfor ASLR safety. - CRT heap: operator_new/malloc crash cross-module (VS2003 CRT critical sections). Use
HeapAlloc(GetProcessHeap(), 0, size)directly. - Struct sizes: Ball=0xC60, Scene=varies (0x6000+), App=0x2328, CollisionLevel=0x10D0, MeshBuffer=0x874.
- 16384 triangle cap: Game crashes if a single level exceeds 2^14 triangles.
- String format: Length-prefixed (int32 + data with NUL terminator).
- Axis swap: All positions in MW files are x,z,y (Max Z-up → engine Y-up). NOT in-engine — only in file I/O.
- Name writing rule: Exporter only writes geom names where
name[1] == ':'or name contains"NOCOLLIDE". Custom objects must use aX:prefix to have their name preserved. - CollisionLevel: Each level's collision geometry is a separate MeshWorld loaded via
Level_LoadCollision. Named collision faces are stored as MeshBuffer objects in the MeshWorld's object list.
🔗 Related Documents
D3D8 [[14178578133750|rendering pipeline]] Deep D
D3D8 Rendering Pipeline Deep Dive
Overview
Hamsterball uses Direct3D 8 (D3D8) for all rendering. The pipeline is organized
into multiple passes with specific render states for each. Graphics_RenderScene
(0x454BC0) is the main entry point called each frame.
Graphics Struct (0x800 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x154 | IDirect3DDevice8* | D3D8 device pointer |
| +0x224 | float[16] | View matrix |
| +0x264 | float[16] | Projection matrix |
| +0x2A4 | float[16] | World matrix |
| +0x5C | void* | D3D8 vtable |
| +0x710 | void*[8] | Render pass list (8 passes) |
| +0x730 | DWORD | Z enable state |
| +0x734 | byte | Z write enable |
| +0x738 | DWORD | Alpha blend enable |
| +0x73C | float | Z near |
| +0x740 | float | Z far |
| +0x748 | Matrix* | Frustum matrix |
Render Pipeline (Graphics_RenderScene 0x454BC0)
Pass 1: Setup Lights
Graphics_SetupLights(param_1); // +0x454630
Sets up D3D8 light sources before rendering.
Pass 2: Copy Matrices
Copies view/projection/world matrices from Graphics struct to local variables:
- +0x224 → local_40 (view, 16 floats)
- +0x264 → local_40 (projection, 16 floats)
- +0x2A4 → auStack_8c (world, 16 floats)
Pass 3: Set View Matrix
Gfx_SetViewMatrix(param_1, local_40); // +0x454C18
D3DDevice_SetTransform(D3DTS_VIEW, ...)
Applies view matrix to D3D device.
Pass 4: Compute Frustum (First Pass)
Matrix_ComputeFrustum(Graphics+0x748);
D3DDevice_SetTransform(D3DTS_PROJECTION, 0x100);
Computes view frustum for culling, sets projection.
Pass 5: Render Pass 1 (Opaque Objects)
Iterates 8 render passes at Graphics+0x710:
for (i = 0; i < 8; i++) {
if (pass[i] != NULL) {
pass[i]->vtable[0x3](); // Render pass
}
}
Pass 1 typically renders opaque geometry (no alpha).
Pass 6: Z-Buffer Management
// Save current Z state
DWORD saved_z_enable = Graphics+0x730;
byte saved_z_write = Graphics+0x734;
// Disable Z write for alpha pass
D3DDevice_SetRenderState(D3DRS_ZWRITEENABLE, FALSE);
// Set viewport Z
Graphics_SetViewportZ(near, far);
// Enable alpha blending
D3DDevice_SetRenderState(D3DRS_ALPHABLENDENABLE, TRUE);
Switches from opaque to alpha-transparent rendering.
Pass 7: Render Pass 2 (Alpha Objects)
Renders same 8 passes again but with alpha-enabled states.
Pass 8: Present
D3DDevice_BeginScene(); // vtable[0x88]
D3DDevice_EndScene(); // vtable[0x78]
Presents frame to screen.
Key D3D8 Render States
| State | Value | Purpose |
|---|---|---|
| D3DRS_ZENABLE | 0x8B | Z-buffer enable |
| D3DRS_ZWRITEENABLE | 0x1C | Z write enable |
| D3DRS_ALPHABLENDENABLE | 0x22 | Alpha blending |
| D3DTS_VIEW | 0x100 | View matrix transform |
| D3DTS_PROJECTION | 0x102 | Projection matrix transform |
| D3DTS_WORLD | 0x100 | World matrix transform |
Scene Rendering (Scene_RenderAllObjects 0x45E0E0)
Called within render passes. Iterates all scene objects:
- For each gadget in Scene+0x858 (gadget list):
- gadget->vtable0xC = Render method
- For each object in Scene+0x25B8 (object list):
- object->vtable0xC = Render method
Material & Shader System
Graphics_ApplyMaterialAndDraw (0x455110)
Applies material properties before drawing:
void Graphics_ApplyMaterialAndDraw(Mesh *mesh, Material *mat) {
// Set diffuse color
D3DDevice_SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, ...);
// Set ambient
D3DDevice_SetRenderState(D3DRS_AMBIENTMATERIALSOURCE, ...);
// Set specular
D3DDevice_SetRenderState(D3DRS_SPECULARMATERIALSOURCE, ...);
// Apply texture
if (mat->texture != NULL) {
D3DDevice_SetTexture(0, mat->texture->d3d_tex);
}
// Draw mesh
Mesh_Draw(mesh);
}
Texture Loading (Graphics_FindOrCreateTexture 0x455C50)
- Checks texture cache first
- Loads PNG/BMP via D3DXCreateTextureFromFile
- Stores in cache for reuse
- 7 xrefs - called for all texture loads
Depth Bias & Shadow Rendering
The game uses depth bias for shadow mapping:
Graphics+0x730 = z_enable flag
Graphics+0x73C = z_near (viewport near)
Graphics+0x740 = z_far (viewport far)
Graphics+0x738 = alpha_blend_enable
These are toggled between opaque and alpha passes.
Viewport Management
Graphics_SetViewport (0x454F10)
Sets D3D viewport from Graphics struct:
- X, Y position
- Width, Height
- MinZ, MaxZ (depth range)
Graphics_SetProjection (0x454AB0)
31 xrefs - most called graphics function.
Sets projection matrix with FOV and aspect ratio.
Lighting
Graphics_SetupLights (0x454630)
Configures up to 8 D3D lights:
- Light type (point/spot/directional)
- Position/Direction
- Diffuse/Specular/Ambient colors
- Attenuation factors
Complete Graphics API
| Address | Function | Purpose |
|---|---|---|
| 0x4542C0 | Graphics_ctor | Initialize graphics engine |
| 0x453B50 | Graphics_BeginFrame | Start frame, clear buffers |
| 0x454BC0 | Graphics_RenderScene | Main render entry |
| 0x454AB0 | Graphics_SetProjection | Set perspective projection |
| 0x454B50 | Graphics_SetViewport | Set viewport dimensions |
| 0x454F10 | Graphics_SetViewport | Set viewport (8 xrefs) |
| 0x453C90 | Graphics_CreateDevice | Create D3D8 device |
| 0x455110 | Graphics_ApplyMaterialAndDraw | Material + mesh draw |
| 0x455C50 | Graphics_FindOrCreateTexture | Load/cached texture |
| 0x455D60 | Graphics_DrawScreenRect | 2D screen rectangle (63 xrefs!) |
| 0x455A90 | Graphics_PresentOrEnd | Present to screen |
| 0x454630 | Graphics_SetupLights | Configure D3D lights |
| 0x453900 | Graphics_ClearViewport | Clear color/depth buffers |
| 0x453970 | Graphics_SetCullMode2 | Set cull mode |
| 0x4539A0 | Graphics_SetViewportZ | Set depth range |
| 0x454190 | Graphics_SetRenderMode | Set render mode flags |
| 0x454D30 | Graphics_Reset | Reset graphics state |
| 0x455A60 | Graphics_Defaults | Set default render states |
| 0x454550 | Graphics_Cleanup | Shutdown graphics |
| 0x455360 | Graphics_dtor | Destructor |
🔗 Related Documents
Deep-Dive
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/death-pending-flag-deep-dive.md)
Deep-Dive: Ball+0x2E9 (impact_shatter)
Overview
Ball+0x2E9 is a sticky limit/trajectory flag that, once set, permanently alters ball collision behavior until the ball is fully reconstructed. It is NOT an "on_ramp" flag, NOT a ground-contact flag, and NOT cleared per-frame.
Naming History
Previous documentation labeled this field as on_ramp, on_surface, flag2, or is_teleporting. All of these labels are wrong. The correct name is impact_shatter because it limits trajectory application and can trigger ball shatter.
Initialization
| Function | Address | Action | Byte Offset |
|---|---|---|---|
Ball_ctor2 |
0x004039E0 | *(byte*)((int)this + 0x2E9) = 0 |
0x2E9 (correct, uses (int) cast) |
Setting
Only set in Ball_Update (0x00405E00), at L798:
// Inside collision type 5 (floor) handler:
if (piVar16[0x15] > 1.0 && is_shrunk == 0) {
*(byte*)((int)param_1 + 0x2E9) = 1; // SET (uses (int) cast = byte offset 0x2E9)
Scene_SetCamera(param_1[5], param_1, 1);
Graphics_SetViewport(..., param_1[0x59], param_1[0x5A]);
// Viewport bounds check → may set swallow flag (param_1[0xBA] = 1)
}
Trigger conditions:
- Ball is colliding with a type-5 (floor) surface
- Surface speed (
piVar16[0x15], collision_obj+0x54) > 1.0 is_shrunk(ball+0xC4C) == 0 (ball is NOT in shrunk state)
When these conditions are met, the flag is set AND:
Scene_SetCamerais called (camera follows ball)Graphics_SetViewportsets up the viewport around the ball position- A viewport bounds check may set the swallow flag (
ball+0xBA = 1), which triggers respawn
Clearing
Ball_FindClosestRespawnPoint (0x00405190) clears the flag at address 0x00405262:
00405262: C6 86 E9 02 00 00 00 MOV byte [ESI+0x2E9], 0
The Ghidra decompilation shows *(undefined1 *)(param_1 + 0x2e9) = 0 without an (int) cast. Previous documentation (ball-ground-detection.md) claimed this was int* arithmetic writing to +0xBA4 instead of +0x2E9. This was wrong. The actual disassembly confirms it is MOV byte [ESI+0x2E9], 0 — a direct byte write to offset 0x2E9 via the ModRM 9E encoding ([ESI+disp32]). The flag IS properly cleared on respawn.
Ball_ctor2 (0x004039E0) also initializes it to 0:
; At ctor2+0x1FE: 88 9E E9 02 00 00 = MOV [ESI+0x2E9], BL (BL pre-loaded with 0)
Result: impact_shatter is cleared both on respawn (Ball_FindClosestRespawnPoint) and on full reconstruction (Ball_ctor2). It is NOT sticky — the previous "sticky flag" claim was based on an incorrect int* arithmetic assumption that the disassembly disproves.
Effects When Set (impact_shatter = 1)
1. Skip Ball_ApplyTrajectory (L498)
if (piVar16[0] == 1) { // type 1 = surface/wall collision
if (piVar16[0x19] == unaff_EBP) { // belongs to this board
if (param_1[0xBB] > 1 && impact_shatter == 0) {
Ball_ApplyTrajectory(param_1); // BOUNCE/REFLECT
}
When impact_shatter = 1: Ball_ApplyTrajectory is skipped. The ball does not bounce off walls. This means the ball slides along surfaces instead of reflecting.
2. Trigger Ball_Shatter on Wall Hit (L502)
if (impact_shatter == 1) {
if (velocity_check_based_on_axis) {
(*vtable[8])(); // calls Ball_FallDeath = Ball_Shatter!
}
}
The velocity check depends on ball+0x1D2 (axis selector):
- Axis 0 (Y):
board+0xCA8 >= 0.0(ball moving up or stationary) - Axis 1 (X):
board+0xCA4 < 0.0(ball moving in negative X) - Axis 2 (Z):
board+0xCAC >= 0.0(ball moving in positive Z or stationary)
If the ball is moving in the "wrong" direction for the current axis AND hits a wall, the ball shatters (breaks into debris pieces, plays sound, triggers respawn).
Ball_FallDeath (0x00409480, vtable[8]):
- Sets
ball+0x2E8 = 1(shattered flag) - Checks
is_shrunk(0xC4C) to select which shatter sound to play - Creates 3
Ball_Split_ctordebris pieces at the ball's position - Copies velocity, position, radius to each debris piece
- Creates
ArenaScoreParticleobjects (score popup particles) - This is essentially the same as
Ball_Shatter(0x00408D70) but called from a different path
3. Dead Code: Position-Match Shatter (L642)
if (impact_shatter != 0 && param_1[0xC9] == 0) {
if (ABS(ball_pos - ramp_entry_pos) < _DAT_004cf4f8) { // threshold = 0.0
(*vtable[8])(); // Ball_Shatter
}
}
_DAT_004cf4f8 = 0.0, and ABS(x) < 0.0 is always false for any float. This code path never executes.
Interaction with is_shrunk (0xC4C)
The impact_shatter is only SET when is_shrunk == 0. If the ball is in shrunk state (our half-size mod), impact_shatter cannot be set by type-5 floor collisions. This means:
- Shrunk balls never get their trajectory limited
- Shrunk balls never trigger the shatter-on-wall-hit behavior
- The camera/viewport setup at L798 is also skipped for shrunk balls
This is likely intentional game design: in odd race, when the ball is shrunk inside the shrink pipes, it shouldn't shatter from floor contact.
Summary Table
| Aspect | Detail |
|---|---|
| Field | Ball+0x2E9 (byte) |
| Name | impact_shatter |
| Init | 0 (by Ball_ctor2) |
| Set by | Type-5 floor collision, speed > 1.0, is_shrunk == 0 |
| Cleared by | Ball_FindClosestRespawnPoint (0x00405262: MOV byte [ESI+0x2E9],0) and Ball_ctor2 (0x004039E0+0x1FE: MOV [ESI+0x2E9],BL) |
| Effect 1 | Skip Ball_ApplyTrajectory — no wall bouncing |
| Effect 2 | Trigger Ball_Shatter on wall hit if moving in "wrong" direction |
| Effect 3 | Dead code: position-match shatter (threshold = 0.0, never triggers) |
| vtable[8] | Ball_FallDeath (0x00409480) — shatter function called from this path |
Interaction with is_shrunk |
is_shrunk=1 prevents impact_shatter from being set |
🔗 Related Documents
DirectInput & Input Handling S
DirectInput & Input Handling System
Overview
Hamsterball uses DirectInput8 (DirectInput8Create at 0x47C7F0) for input from
keyboard, mouse, and joystick devices. The input system supports up to 4 players
with different device types: 1=keyboard, 2=mouse, 4=joystick1, 5=joystick2.
InputDevice Structure (0x91C bytes)
Created by App_CreateInputDevice (0x46C050), actually calls SoundDevice_ctor
(this appears to be an error in naming — it's a generic Device base class).
Key offsets (based on App+0x1E4, 0x1E8 player device pointers):
| Offset | Type | Description |
|---|---|---|
| +0x04 | int | Device type (1=keyboard, 2=mouse, 4=joystick) |
| +0x10 | void* | DirectInput device handle |
| +0x18C | DIDEVICEINSTANCE | Device instance data |
| +0x10C | int | Joystick X axis value |
| +0x110 | int | Joystick Y axis value |
| +0x114 | int | Joystick Z axis value |
| +0x434 | void* | Keyboard key state array |
Input Device Initialization
App_Initialize_Full (0x429530) — Input Setup Steps 15-22
Step 15: InputDevice_SetType(App+0x1E4, 1) // Player 1 = keyboard
Step 16: InputDevice_SetType(App+0x1E8, 1) // Player 2 = keyboard
Step 17: InputDevice_SetType(App+0x1E4, 2) // Player 1 = mouse
Step 18: InputDevice_SetType(App+0x1E8, 2) // Player 2 = mouse
Step 19: InputDevice_SetType(App+0x1E4, 4) // Player 1 = joystick 1
Step 20: InputDevice_SetType(App+0x1E8, 4) // Player 2 = joystick 1
Step 21: InputDevice_SetType(App+0x1E4, 5) // Player 1 = joystick 2
Step 22: InputDevice_SetType(App+0x1E8, 5) // Player 2 = joystick 2
The game tries all device types during initialization. The final type
is set based on which devices are detected/connected.
DirectInput8Create (0x47C7F0)
Standard DirectInput COM entry point:
result = DirectInput8Create(hinst, 0x800, IID_IDirectInput8W, &di, NULL);
InputDevice Functions
| Address | Function | Description |
|---|---|---|
| 0x46C050 | App_CreateInputDevice | Allocate 0x91C device struct |
| 0x46C110 | App_CreateInputHandler | Create input handler |
| 0x6DFC0 | InputDevice_SetType | Set device type (1=kb, 2=mouse, 4=joy1, 5=joy2) |
| 0x46E0B0 | Input_IsKeyDown | Check if DIK key is pressed |
| 0x46EBD0 | InputDevice_PollAndRelease | Poll device, release buffers |
| 0x46EC30 | Ball_GetInputForce | Get 2D force vector from input |
| 0x46EE10 | InputHandler_Ctor | Input handler constructor |
Ball Input Force (Ball_GetInputForce 0x46EC30)
Maps raw device input to ball movement force:
Mode 1 — Keyboard
Key state array at: *(int*)(ball + 4) + 0x434
DIK codes at: +0x50C (left), +0x510 (right), +0x514 (up), +0x518 (down)
Check: key_state[DIK_code + 0xC] & 0x80 (high bit = pressed)
UP: force_y = -0.5 // forward
DOWN: force_y = 1.0 // backward
LEFT: force_x = -1.0
RIGHT: force_x = 1.0
Mode 2 — Mouse
center_x = gfx->width / 2
center_y = gfx->height / 2
GetCursorPos(&cursor)
force_x = cursor.x - center_x
force_y = cursor.y - center_y
If mouse capture active: SetCursorPos(center_x, center_y) // recenter
Mode 3-6 — Joystick (4 devices)
pos.x = joystick->axis_x / 100
pos.y = joystick->axis_y / 100
NormalizeAndScale to unit circle
force_x = normalized.x
force_y = normalized.y
Output is multiplied by per-ball speed scale (ball+0xC).
Input Checking
Input_CheckKeyCombo (0x428F10)
14 xrefs — checks for keyboard shortcut combos:
- Checks multiple key codes simultaneously
- Returns TRUE if all keys in combo are pressed
Input_CheckJoystickButtons (0x428FB0)
1 xref — checks joystick button states.
Input Dispatch (Scene_HandleInput 0x4692F0)
Main input routing (51 xrefs):
1. Clear active_input_object at Scene+0x864
2. Iterate gadgets in Scene+0x858 (AthenaList)
3. For each gadget where input_active (+0x16 != 0):
- Call gadget->vtable[1](msg1, msg2) // input handler
- If gadget captures (+0x14 != 0): active = gadget
- If player1 (+0x19 != 0): channel = App+0x1E4
- If player2 (+0x15 != 0): channel = App+0x1E8
4. Play sound via App+0x1DC
Mouse Input
Input_OnMouseDown (0x46C760)
2 xrefs — mouse button down event.
Input_OnMouseUp (0x46C430)
2 xrefs — mouse button up event.
Input_OnMouseUpCapture (0x46C3C0)
2 xrefs — mouse up with cursor capture/release.
Key Codes (DIK Constants)
Keyboard uses DirectInput key constants (DIK_*):
| DIK Code | Key |
|---|---|
| DIK_UP | Arrow Up |
| DIK_DOWN | Arrow Down |
| DIK_LEFT | Arrow Left |
| DIK_RIGHT | Arrow Right |
| DIK_SPACE | Spacebar |
| DIK_ESCAPE | Escape |
These are stored in the InputDevice structure and looked up by
Ball_GetInputForce to check key states.
Input in Loading Screen
LoadingScreen_HandleInput (0x42D020)
1 xref — handles input during the loading screen (resource loading phase).
Allows user to skip or cancel during initial load.
Registration Dialog
RegisterDialog_HandleInput (0x4475A0)
4 xrefs — handles input in the registration dialog.
Used for entering registration keys.
Key Input Constants
DirectInput keyboard scan codes used:
- 0x50C (DIK_LEFT) — stored at InputDevice+0x50C
- 0x510 (DIK_RIGHT) — stored at InputDevice+0x510
- 0x514 (DIK_UP) — stored at InputDevice+0x514
- 0x518 (DIK_DOWN) — stored at InputDevice+0x518
The key state array format:
key_state = *(int*)(device + 0x434)
key_pressed = key_state[DIK_code + 0xC] & 0x80
Input Device Detection
The game attempts to detect and configure input devices during startup.
App_CreateInputHandler (0x46C110) manages device enumeration.
Multiple input types can be configured simultaneously:
- Player 1 can use keyboard, mouse, or joystick
- Player 2 can use keyboard, mouse, or joystick
- Each player's device is stored at App+0x1E4 and App+0x1E8
Input Sound Feedback
Input events trigger sound effects via App+0x1DC:
- Menu navigation sounds
- Button click sounds
- Race start countdown sounds
🔗 Related Documents
Direction Detect Mod
Direction Detect Mod
Detects which direction the hamster ball is facing and displays it as an on-screen compass overlay.
Features
- On-screen compass: Shows facing direction (N/NE/E/SE/S/SW/W/NW) and heading angle in degrees
- Velocity display: Shows current XZ velocity vector and speed magnitude
- Engine facing fields: Cross-references the engine's own
facing_directioncos/sin fields at ball+0x194/0x198 - Toggle: Press D to show/hide the overlay
- Log file: Writes direction data to
direction_log.txtin the game directory for debugging
How It Works
The mod uses two mechanisms:
-
Background polling thread (~60fps): Finds Player 1's ball by scanning the Scene's ball list (Scene+0x29D4 AthenaList), then reads:
- Ball velocity X/Z (ball+0x170/0x178) → computes heading via
atan2f(vz, vx) - Ball speed (ball+0x188)
- Engine's facing direction cos/sin (ball+0x194/0x198)
- Ball velocity X/Z (ball+0x170/0x178) → computes heading via
-
Render hook: Hooks
Graphics_PresentOrEnd(0x455A90) via a code cave. Draws text before calling the original Present function — this is the correct timing (after viewport clear, before Present/EndScene).
Compass Mapping
The Hamsterball coordinate system is Y-up, with X and Z as the horizontal plane:
atan2(vz, vx)gives the heading angle- 0° = East (+X), 90° = North (+Z), 180° = West (-X), 270° = South (-Z)
Installation
- Rename original
bass.dll→bass_real.dllin the Hamsterball directory - Copy this
bass.dllto the same directory - Launch
Hamsterball.exe
Build
i686-w64-mingw32-gcc -shared -o bass.dll direction_detect.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc -Wl,--add-stdcall-alias -lm
Controls
| Key | Action |
|---|---|
| D | Toggle direction overlay on/off |
Display
Three lines of text appear at top-center of the screen (x=360, y=20):
Facing: NE (47 deg)
Vel: (123, -45) Spd: 131.0
Engine: cos=0.731 sin=0.682
Struct Offsets Used
| Offset | Type | Field |
|---|---|---|
| ball+0x018 | int | player_index (0 = Player 1) |
| ball+0x164 | float | position X |
| ball+0x168 | float | position Y |
| ball+0x16C | float | position Z |
| ball+0x170 | float | velocity X |
| ball+0x174 | float | velocity Y |
| ball+0x178 | float | velocity Z |
| ball+0x188 | float | speed (velocity magnitude) |
| ball+0x194 | float | facing_direction cos (engine-computed) |
| ball+0x198 | float | facing_direction sin (engine-computed) |
Architecture
- Pattern 4 (volatile flag + polling thread): The background thread reads ball state and stores it in
volatileglobals. The render hook's code cave calls a C function (draw_direction_overlay) that reads these globals and draws text usingUI_DrawTextShadow_Wrapper(0x409B90). - The code cave at
Graphics_PresentOrEnd(0x455A90) does: PUSHFD/PUSHAD → CALL draw_overlay → POPAD/POPFD → execute original 7 bytes → JMP back. - Text is drawn using
UI_DrawTextShadow_Wrapper(0x409B90, 15 params,RET 0x3C) with white text and black shadow for readability.
🔗 Related Documents
docs/agent-knowledge
docs/agent-knowledge
Self-contained bootstrapping documentation for future agents continuing the Hamsterball reverse-engineering project.
Start here: INDEX.md
This package captures project setup, Ghidra workflow, struct verification, common RE patterns, reimplementation lessons, and known failure modes. It is meant to be read by an autonomous agent with no prior project context.
🔗 Related Documents
Drawbridge & Trapdoor
Drawbridge & Trapdoor: Complete Reverse Engineering
Overview
Both objects are spawned by CreateTowerObjects (factory @ 0x0040D7C0) in Tower Race.
They can be spawned globally using a CEA hotkey, but require different setup.
1. DRAWBRIDGE (Glass_Level)
Factory Spawn Sequence
; At 0x40D956-0x40D9DF in CreateTowerObjects
push 0x113C ; alloc size = 4412 bytes
call operator_new (0x4BA57B)
add esp, 4
; if alloc succeeded:
mov edx, [esi+0x4370] ; mesh = Board+0x4370 (Drawbridge MeshWorld)
push edx ; param_2 = mesh_ptr
push esi ; param_1 = Board
mov ecx, eax ; this = alloc
call Glass_Level_ctor (0x4384A0) ; ret 8
; Copy position from param_block+4/+8/+0xC to obj+0x10D8 (3 floats)
push edi ; obj
lea ecx, [esi+0x2578]
call AthenaList_Append (0x453810) ; Board+0x2578 (general list)
push edi ; obj
lea ecx, [esi+0x4BE8]
call AthenaList_Append (0x453810) ; Board+0x4BE8 (drawbridge list)
Constructor: Glass_Level_ctor @ 0x4384A0
Calling convention: __thiscall — ecx=alloc, push Board, push mesh_ptr → ret 8
Steps:
- Calls
Stands_ctor(this, mesh_ptr)@ 0x462850 — base class init- Sets vtable to 0x4D8FB0 (Stands base)
- Inits AthenaLists at +0x18, +0x488, +0x8A0, +0xCB8
- Copies mesh data (vertices, spatial tree) from mesh_ptr
- Creates Timer (0x44 bytes) at +0x434
- Sets vtable to
0x4D5060(Glass_Level/Impossible_Level vtable) - Stores Board at +0x10D0
- Zeroes position at +0x10D8/+0x10DC/+0x10E0
- Sets +0x10E4 = 0 (rotation/state)
- Sets +0x10E8 = -1.0 (0xBF800000, break direction)
- Allocates CollisionLevel (0x10D0 bytes) → calls
CollisionLevel_ctorWithLevel(alloc, this)@ 0x465080- Stores result at +0x10D4
- Links Timer from this+0x434 into CollisionLevel+0x434
- Creates Timer array (2 × 0x44 bytes) via
_eh_vector_constructor_iterator - Allocates TimerArray (0x90 bytes) via
0x475980→ stores at +0x10EC - Looks up chain bridge collision strings:
- "Chain1Bridge" @ 0x4D5BB8 → stores at obj+0x10F0
- "Chain2Bridge" @ 0x4D5BA8 → stores at obj+0x10FC
- "Chain1Wall" @ 0x4D5B9C → stores at obj+0x1120
- "Chain2Wall" @ 0x4D5B90 → stores at obj+0x112C
Struct Layout (0x113C bytes)
| Offset | Size | Description |
|---|---|---|
| +0x000 | 4 | vtable ptr (0x4D5060) |
| +0x004 | 4 | mesh data ptr (from Stands_ctor) |
| +0x018 | 0x420 | AthenaList (inline objects) |
| +0x438 | 0x50 | AthenaList |
| +0x434 | 4 | Timer ptr (0x44 bytes, owned) |
| +0x480 | 4 | mesh field (from Stands_ctor) |
| +0x488 | 0x418 | AthenaList |
| +0x8A0 | 0x418 | AthenaList |
| +0xCB8 | 0x418 | AthenaList |
| +0x10D0 | 4 | Board ptr (parent scene) |
| +0x10D4 | 4 | CollisionLevel ptr (owned, 0x10D0 bytes) |
| +0x10D8 | 4 | position X |
| +0x10DC | 4 | position Y |
| +0x10E0 | 4 | position Z |
| +0x10E4 | 4 | rotation/state (0) |
| +0x10E8 | 4 | break direction (-1.0 = 0xBF800000) |
| +0x10EC | 4 | TimerArray ptr (owned, 0x90 bytes) |
| +0x10F0 | 12 | Chain1Bridge position (Vec3) |
| +0x10FC | 12 | Chain2Bridge position (Vec3) |
| +0x1108 | 1 | flag (set to 1 in ctor) |
| +0x1120 | 12 | Chain1Wall position (Vec3) |
| +0x112C | 12 | Chain2Wall position (Vec3) |
| +0x1138 | 4 | state flag |
Vtable: 0x4D5060
| Index | Address | Function |
|---|---|---|
| [0] | 0x438730 | scalar_deleting_dtor |
| [9] | 0x45DFD0 | SceneObject_CallUpdate wrapper |
| [18] | 0x43F2F0 | Update (calls base + chain animation) |
| [21] | 0x45DF90 | SceneObject_CallRender (tail-call) |
| [22] | 0x45DF80 | SceneObject_CallTimerUpdate (tail-call) |
| [24] | 0x438830 | scalar_deleting_dtor (alt) |
Collision
- No dedicated handler in
TowerCollisionEvents(0x40DCD0) - Collision is via the CollisionLevel sub-object at obj+0x10D4
- CollisionLevel has its own spatial tree built from the Drawbridge mesh
- Chain bridge/wall names are looked up in the mesh's entity table for collision triggers
Mesh Dependency
- Board+0x4370 = MeshWorld("Levels\Level4-Drawbridge")
- Loaded by
LevelBoard_Tower_ctor(0x41E340) at line 72 - On non-Tower levels: must load manually via
MeshWorld_ctor(0x10D0, App+0x174, "Levels\\Level4-Drawbridge")
AthenaList Dependencies
- Board+0x2578 (general objects) — initialized by Board_ctor on ALL levels ✓
- Board+0x4BE8 (drawbridge list) — initialized by LevelBoard_Tower_ctor ONLY
2. TRAPDOOR (GlassStands)
Factory Spawn Sequence
; At 0x40DA82-0x40DB5C in CreateTowerObjects
push 0x10F8 ; alloc size = 4344 bytes
call operator_new (0x4BA57B)
add esp, 4
; if alloc succeeded:
push esi ; param_1 = Board
mov ecx, eax ; this = alloc
call GlassStands_Ctor (0x438290) ; ret 4
; Copy position from param_block+4/+8/+0xC to obj+0x10E0 (3 floats)
push edi ; obj
lea ecx, [esi+0x2578]
call AthenaList_Append (0x453810) ; Board+0x2578 (general list)
push edi ; obj
lea ecx, [esi+0x47D0]
call AthenaList_Append (0x453810) ; Board+0x47D0 (trapdoor list)
; Append sub-objects to collision lists:
mov ecx, [edi+0x10D8] ; sub-object 1 (Stands)
push ecx
lea ecx, [esi+0xCD4]
call AthenaList_Append ; Board+0xCD4
mov edx, [edi+0x10DC] ; sub-object 2 (TipperVisual)
push edx
lea ecx, [esi+0x10EC]
call AthenaList_Append ; Board+0x10EC
; Append to collision levels:
mov ecx, [esi+0x8AC] ; Board+0x8AC (primary CollisionLevel)
mov eax, [edi+0x10D8] ; sub-object 1
mov ecx, [ecx+0x480] ; CollisionLevel+0x480
push eax
add ecx, 0x1C ; AthenaList at CollisionLevel+0x480+0x1C
call AthenaList_Append
mov eax, [edi+0x10DC] ; sub-object 2
mov ecx, [esi+0x8B0] ; Board+0x8B0 (secondary CollisionLevel)
push eax
add ecx, 0x18 ; AthenaList at CollisionLevel+0x18
call AthenaList_Append
Constructor: GlassStands_Ctor @ 0x438290
Calling convention: __thiscall — ecx=alloc, push Board → ret 4
Steps:
- Reads App from Board+0x878
- Calls
Stands_ctor(this, App+0x594)@ 0x462850 — base class init with Trapdoor1 mesh- App+0x594 = MeshWorld("Levels\Level4-Trapdoor1") — globally pre-loaded!
- Sets vtable to
0x4D4FF8(GlassStands vtable) - Stores Board at +0x10D0
- Allocates TipperVisual (0x10D0 bytes) using App+0x59C (CollisionLevel of Trapdoor1)
- Calls
0x4661A0(TipperVisual_ctor) with App+0x59C as mesh - Stores at +0x10D4
- Calls
Level_LoadMeshes(+0x10D4, this)@ 0x465200
- Calls
- Allocates Stands (0x10D0 bytes) using App+0x598 (MeshWorld of Trapdoor2)
- Calls
Stands_ctor(alloc, App+0x598)@ 0x462850 - Stores at +0x10D8
- Calls
- Allocates TipperVisual (0x10D0 bytes) using App+0x5A0 (CollisionLevel of Trapdoor2)
- Calls
0x4661A0(TipperVisual_ctor) with App+0x5A0 as mesh - Stores at +0x10DC
- Calls
Level_LoadMeshes(+0x10DC, +0x10D8)@ 0x465200
- Calls
- Sets +0x10EC = 0 (timer/state)
- Sets +0x10F0 = -1.0 (0xBF800000, break direction)
- Sets +0x10F4 = 0 (triggered flag)
Struct Layout (0x10F8 bytes)
| Offset | Size | Description |
|---|---|---|
| +0x000 | 4 | vtable ptr (0x4D4FF8) |
| +0x004 | 4 | mesh data ptr (from Stands_ctor) |
| +0x018 | 0x420 | AthenaList (inline objects) |
| +0x434 | 4 | Timer ptr (0x44 bytes, owned) |
| +0x480 | 4 | mesh field |
| +0x488 | 0x418 | AthenaList |
| +0x8A0 | 0x418 | AthenaList |
| +0xCB8 | 0x418 | AthenaList |
| +0x10D0 | 4 | Board ptr (parent scene) |
| +0x10D4 | 4 | TipperVisual ptr (Trapdoor1 collision, owned) |
| +0x10D8 | 4 | Stands ptr (Trapdoor2 mesh, owned) |
| +0x10DC | 4 | TipperVisual ptr (Trapdoor2 collision, owned) |
| +0x10E0 | 4 | position X |
| +0x10E4 | 4 | position Y |
| +0x10E8 | 4 | position Z |
| +0x10EC | 4 | timer/state (0) |
| +0x10F0 | 4 | break direction (-1.0) |
| +0x10F4 | 4 | triggered flag (0 = closed, 10 = opening) |
Vtable: 0x4D4FF8
| Index | Address | Function |
|---|---|---|
| [0] | 0x4383F0 | scalar_deleting_dtor |
| [9] | 0x45DFD0 | SceneObject_CallUpdate wrapper |
| [11] | 0x4342C0 | GlassStands_Dtor (cleanup) |
| [18] | 0x45E0E0 | Update (Stands_Update base) |
| [21] | 0x45DF90 | SceneObject_CallRender (tail-call) |
| [22] | 0x45DF80 | SceneObject_CallTimerUpdate (tail-call) |
| [24] | 0x423E0000 | (float data, NOT a function — vtable ends at [23]) |
Note: vtable at 0x4D4FF8 has only 24 entries (0x60 bytes). The values at [24]=0x423E0000
and [25]=0x42A00000 are float constants (40.0 and 80.0), not function pointers.
The next vtable (Glass_Level at 0x4D5060) starts at [26].
Collision
Handled in TowerCollisionEvents (0x40DCD0):
-
"N:TRAPDOOR" →
Trapdoor_Activate(obj)@ 0x438410- Iterates Board+0x47D0 (trapdoor list) via Board+0x4BDC (heap array)
- Matches by obj+0x10D4 or obj+0x10DC == colliding object
- Sets obj+0x10F4 = 10 (activation timer)
- Plays 3D sound at obj+0x10E0/+0x10E4/+0x10E8
-
"E:OPENSESAME" →
Trapdoor_Open(obj)@ 0x4344D0- Iterates Board+0x4BE8 (drawbridge list) via Board+0x4FF4 (heap array)
- Opens the first trapdoor in the list
Trapdoor_Activate @ 0x438410
if (obj+0x10F4 == 0): // only if not already activated
if (obj+0x10EC < threshold): // timer check
play 3D sound at position
obj+0x10F4 = 10 // set activation timer
Trapdoor_Open @ 0x4344D0
if (obj+0x10E4 == threshold):
obj+0x10E4 = 1.0 (0x3F800000)
Mesh Dependency
NONE! Trapdoor uses App-level meshes that are globally pre-loaded:
- App+0x594 = MeshWorld("Levels\Level4-Trapdoor1") — loaded by resource loader (0x4298C0)
- App+0x598 = MeshWorld("Levels\Level4-Trapdoor2")
- App+0x59C = CollisionLevel(App+0x594)
- App+0x5A0 = CollisionLevel(App+0x598)
AthenaList Dependencies
- Board+0x2578 (general) — initialized by Board_ctor ✓
- Board+0x47D0 (trapdoor) — initialized by LevelBoard_Tower_ctor ONLY
- Board+0xCD4 — initialized by Board_ctor ✓ (or BoardLevel5)
- Board+0x10EC — initialized by Board_ctor ✓ (or BoardLevel5)
- Board+0x8AC→+0x480→+0x1C — collision list, exists on all levels
- Board+0x8B0→+0x18 — collision list, exists on all levels
Global Spawn Requirements
Drawbridge
- Load mesh:
MeshWorld_ctor(0x10D0, App+0x174, "Levels\\Level4-Drawbridge")→ Board+0x4370 - Init AthenaList:
AthenaList_Init(Board+0x4BE8, 0) - Allocate:
operator_new(0x113C) - Construct:
Glass_Level_ctor(alloc, Board, Board+0x4370)(thiscall, ret 8) - Set position:
obj+0x10D8/+0x10DC/+0x10E0 = X/Y/Z - Append:
AthenaList_Append(Board+0x2578, obj) - Append:
AthenaList_Append(Board+0x4BE8, obj)
Trapdoor
- No mesh loading needed (App+0x594/+0x598/+0x59C/+0x5A0 pre-loaded)
- Init AthenaList:
AthenaList_Init(Board+0x47D0, 0) - Allocate:
operator_new(0x10F8) - Construct:
GlassStands_Ctor(alloc, Board)(thiscall, ret 4) - Set position:
obj+0x10E0/+0x10E4/+0x10E8 = X/Y/Z - Append:
AthenaList_Append(Board+0x2578, obj) - Append:
AthenaList_Append(Board+0x47D0, obj) - Append:
AthenaList_Append(Board+0xCD4, obj+0x10D8)(sub-object 1) - Append:
AthenaList_Append(Board+0x10EC, obj+0x10DC)(sub-object 2) - Append:
AthenaList_Append(Board+0x8AC→+0x480→+0x1C, obj+0x10D8)(collision) - Append:
AthenaList_Append(Board+0x8B0→+0x18, obj+0x10DC)(collision)
Key Function Addresses
| Address | Function | Calling Convention |
|---|---|---|
| 0x4BA57B | operator_new | push size, call, add esp 4 |
| 0x4BA74D | operator_delete (free) | push ptr, call, add esp 4 |
| 0x453810 | AthenaList_Append | ecx=list, push obj, call (ret 4) |
| 0x453210 | AthenaList_Init | ecx=list, push 0, call (ret 4) |
| 0x461510 | MeshWorld_ctor | ecx=alloc, push App+0x174, push path, call (ret 8) |
| 0x462850 | Stands_ctor | ecx=alloc, push mesh_ptr, call (ret 4) |
| 0x465080 | CollisionLevel_ctorWithLevel | ecx=alloc, push source_level, call (ret 4) |
| 0x465200 | Level_LoadMeshes | ecx=dest, push source, call (ret 4) |
| 0x4661A0 | TipperVisual_ctor | ecx=alloc, push mesh_ptr, call (ret 4) |
| 0x4384A0 | Glass_Level_ctor (Drawbridge) | ecx=alloc, push Board, push mesh, call (ret 8) |
| 0x438290 | GlassStands_Ctor (Trapdoor) | ecx=alloc, push Board, call (ret 4) |
| 0x438410 | Trapdoor_Activate | ecx=trapdoor_obj, call |
| 0x4344D0 | Trapdoor_Open | ecx=trapdoor_obj, call |
| 0x40DCD0 | TowerCollisionEvents | thiscall(Board, ball, collObj) |
| 0x40D7C0 | CreateTowerObjects | thiscall, processes MESHWORLD objects |
| 0x41E340 | LevelBoard_Tower_ctor | thiscall, loads Tower meshes + inits lists |
Mesh Path Strings (in .rdata)
| Address | String | Board Offset | Used By |
|---|---|---|---|
| 0x4D099C | Levels\Level4-Drawbridge | +0x4370 | Drawbridge |
| (global) | Levels\Level4-Trapdoor1 | App+0x594 | Trapdoor |
| (global) | Levels\Level4-Trapdoor2 | App+0x598 | Trapdoor |
App-Level Resource Loading (0x4298C0)
All trapdoor meshes are loaded GLOBALLY for every level:
App+0x594 = MeshWorld("Levels\\Level4-Trapdoor1") // mesh
App+0x598 = MeshWorld("Levels\\Level4-Trapdoor2") // mesh
App+0x59C = CollisionLevel(App+0x594) // collision from trapdoor1
App+0x5A0 = CollisionLevel(App+0x598) // collision from trapdoor2
Drawbridge mesh is Tower-specific, loaded only by LevelBoard_Tower_ctor.
🔗 Related Documents
Drop-in Levels Folder (Beginne
Drop-in Levels Folder (Beginner ↔ Intermediate Swapped)
Complete copy of the game's Levels/ folder with Beginner Race and Intermediate Race swapped.
What's swapped
| File | Original | This folder |
|---|---|---|
LevelCascade.MESHWORLD |
Beginner Race | Intermediate Race (Level2 data) |
Level2.MESHWORLD |
Intermediate Race | Beginner Race (LevelCascade data) |
Everything else is original. No .cached or .bak files included.
CRITICAL: Delete .cached files in YOUR game folder!
The game caches pre-processed level data in .cached files. If any .cached files exist in your game's Levels/ folder, the game will use the cached version and IGNORE your swapped .MESHWORLD files.
Before copying this folder:
- Go to your game's
Levels/folder - Delete ALL
*.cachedfiles (e.g.level1.cached,level2.cached,Level2-Bridge.cached, etc.) - Delete ALL
*.bakfiles (e.g.Level1.MESHWORLD.bak) - Delete the entire
Levels/folder (or rename toLevels_backup/) - Copy this entire
levels/folder as the newLevels/folder - Do NOT run the game before installing the bass.dll mod — running the game regenerates
.cachedfiles from whatever.MESHWORLDfiles are present
Level mapping (for reference)
| Race name | Game filename |
|---|---|
| Warm-up Race | Level1.MESHWORLD |
| Beginner Race | LevelCascade.MESHWORLD |
| Intermediate Race | Level2.MESHWORLD |
| Dizzy Race | Level3.MESHWORLD |
| Tower Race | Level4.MESHWORLD |
| Up Race | LevelUp.MESHWORLD |
| Expert Race | Level5.MESHWORLD |
| odd race | Level6.MESHWORLD |
| Toob Race | Level8.MESHWORLD |
| Wobbly Race | Level7.MESHWORLD |
| Glass Race | LevelGlass.MESHWORLD |
| Sky Race | Level9.MESHWORLD |
| Master Race | Level10.MESHWORLD |
| Neon Race | LevelDark.MESHWORLD |
| Impossible Race | LevelImpossible.MESHWORLD |
🔗 Related Documents
Dual Platform Arena v2
Dual Platform Arena v2
Overview
Custom arena replacement for Warm-Up Arena (Arena-WarmUp.MESHWORLD). Features two large circular platforms with C-shaped railings, connected by a wide bridge.
v2 Fixes (from v1)
| Issue in v1 | Fix in v2 |
|---|---|
| Players 1-4 cramped together (single START1-1) | START2-1 through START2-4 spread across platform at 150-unit intervals |
| 8-ball stuck | Removed BADBALL ref points (game spawns 8-balls automatically in arena mode) |
| Platforms too small (r=200) | Bigger platforms: r=350 |
| No railings → balls fall off | C-shaped railings on each platform (40 units high, 60° gap toward bridge) |
| Bridge too narrow (80 units) | Wider bridge: 240 units total width |
| Wrong camera ref name (CameraLocus1) | CAMERALOOKAT (matches original game format) |
| Missing STANDS/SAFESPOT | Added STANDS and 3 SAFESPOT ref points |
Layout
Platform A (pink, r=350) Bridge (brown, 240 wide) Platform B (blue, r=350)
center: (-600, 0, 0) center: (+600, 0, 0)
START2-4(-750,67,+150) START2-4(+750,815,+150)
| |
START2-1(-750,67,-150) [C-railing gap → bridge → gap← C-railing] START2-3(+750,67,-150)
| |
START2-3(-450,67,-150) START2-3(+450,815,-150)
| |
START2-2(-450,67,+150) START2-2(+450,815,+150)
C-shaped railing: 40 units high, gap faces bridge (60° opening)
Geometry
| Component | Vertices | Triangles | Material |
|---|---|---|---|
| Platform A (pink cylinder) | 384 | 128 | (0.99, 0.63, 1.0) |
| Platform B (blue cylinder) | 384 | 128 | (0.42, 0.62, 0.91) |
| Railing A (C-shape, gray) | 900 | 252 | (0.7, 0.7, 0.8) |
| Railing B (C-shape, gray) | 900 | 252 | (0.7, 0.7, 0.8) |
| Bridge (brown box) | 24 | 12 | (0.55, 0.42, 0.30) |
| Total | 2384 | 772 | 5 geoms |
Ref Points (Section 1)
| Name | Position | Purpose |
|---|---|---|
| START2-1 | (-750, 67, -150) | Player 1 spawn (back-left of Platform A) |
| START2-2 | (-450, 67, +150) | Player 2 spawn (front-right of Platform A) |
| START2-3 | (-450, 67, -150) | Player 3 spawn (back-right of Platform A) |
| START2-4 | (-750, 67, +150) | Player 4 spawn (front-left of Platform A) |
| CAMERALOOKAT | (0, 0, 0) | Camera orbit center between platforms |
| PLATFORM × 10 | Various | Arena platform spawn positions |
| SAFESPOT × 3 | On each platform + bridge | Respawn safety points |
| STANDS | (0, -776, 0) | Arena spectator stands |
C-Shaped Railings
Each platform has a curved railing that covers 300° of the circumference, with a 60° gap facing the bridge. The railing is:
- Height: 40 units above platform surface
- Radius: platform_radius + 8 (slightly outside edge)
- Thickness: 10 units (inner r-5 to outer r+5)
- Gap direction: Platform A gap faces +X (toward bridge), Platform B gap faces -X
- Color: Light gray (0.7, 0.7, 0.8)
Installation
# Backup original
cp Levels/Arena-WarmUp.MESHWORLD Levels/Arena-WarmUp.MESHWORLD.bak
# Install custom arena
cp DualPlatformArenaV2.MESHWORLD Levels/Arena-WarmUp.MESHWORLD
Testing Results
Tested in original Hamsterball.exe on Wine/llvmpipe (Xvfb display :99):
- ✅ Level loads correctly — binary format parses without errors (83158 bytes)
- ✅ Race starts — timer counting, ball on platform, TARGET: 15 displayed
- ✅ Ball stays on platform — physics working, ball doesn't fall through
- ✅ 3D geometry renders — platform surface visible
- ⚠️ Materials render dark on Wine/llvmpipe (known D3D8 lighting issue — colors will show on real Windows GPU)
- ⚠️ Camera follows ball closely in race mode — can't see both platforms simultaneously (arena mode uses wider camera)
Regeneration
python3 tools/create_dual_arena_v2.py DualPlatformArenaV2.MESHWORLD
🔗 Related Documents
Entity Limit Fixer v7
Entity Limit Fixer v7
Prevents freezes and crashes when spawning many entities (8-balls, player clones) in Hamsterball arenas.
What it does
When ball count exceeds MAX_BALLS (default 5), all Mesh_FindClosestCollision calls are skipped — returning "no collision" (99999.0f) instead of building an expensive SpatialTree from level geometry each call.
Root cause
Mesh_FindClosestCollision (0x465D90) builds a full SpatialTree + CollisionMesh from level geometry on every call, traverses it, then frees everything. Each call takes ~1ms. Called from 5 call sites:
Ball_Update(0x40651F): 2× per ball per frameBall_Update(0x407557): 2× per ball per frameBall_FindClosestRespawnPoint(0x405C46): ~16× per fallen ball per frameScene_UpdateArenaPhysics(0x4406EE): 1× per ball per frameBall_FindMeshCollision(0x4039CD): 1× per BounceBall per frame
With 10 balls: ~50+ SpatialTree builds per frame → 50ms+ → freeze.
v7 approach
Instead of patching each call site individually (v5/v6 only covered 3 of 5), v7 patches Mesh_FindClosestCollision's entry point directly. A global flag SKIP_COLLISIONS is set once per frame at Scene_UpdateBallsAndState entry when ball count > MAX_BALLS. When the flag is set, the function returns immediately with 99999.0f — covering all 5 call sites with a single patch.
| Patch | Address | Description |
|---|---|---|
| A | 0x41B540 | Hook Scene_UpdateBallsAndState → set SKIP_COLLISIONS flag |
| B | 0x465D90 | Hook Mesh_FindClosestCollision → skip if flag set |
| C | 0x4BA58D | operator_new → return NULL (crash protection) |
| D | 0x4083D9 | Skip AI O(N²) loop 1 when flag set |
| E | 0x408548 | Skip AI O(N²) loop 2 when flag set |
Build
i686-w64-mingw32-gcc -shared -o bass.dll bass_proxy.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc -Wl,--add-stdcall-alias
Adjustable parameters (CE address list)
MAX_BALLS(default 5): Ball count threshold for skipping collisionSKIP_COLLISIONS: Runtime flag (1=skip, 0=normal), set automatically
Behavior
- ≤5 balls: Full collision detection, game runs 100% normally
- >5 balls: All
Mesh_FindClosestCollisioncalls skip the expensive SpatialTree build. Balls won't collide with level geometry but still bounce off each other (ball-ball collision is a separate system). Balls that fall still respawn normally.
Files
EntityLimitFixer.CEA— CE AutoAssembler scriptbass_proxy.c— DLL proxy sourcebass.dll— Compiled DLL proxy
🔗 Related Documents
Entity Performance Fix
Entity Performance Fix
Problem
The game freezes/crashes when too many entities (balls, clones, 8-balls) are active in arenas, especially when they fall/respawn simultaneously.
Root Cause Analysis (Ghidra decompilation trace)
Bottleneck 1: O(N²) Ball-Ball Collision
Function: Scene_UpdateBallsAndState (0x41B540) → Ball_Update (0x405E00)
Each frame, the game iterates ALL balls in the AthenaList at Scene+0x29D4 and calls vtable[4] (Ball_Update) for each. Ball_Update internally:
- Allocates a
SpatialTree(0x20 bytes) viaoperator_newat 0x40689A - Allocates a
CollisionNode(0x14 bytes) viaoperator_newat 0x4068F7 - Recursively traverses the spatial tree via
Collision_TraverseSpatialTree(0x465EF0) - Iterates all collision entries (O(N) per ball)
- For ball-ball collision (type==1): full physics response per pair
Total cost: O(N²) per frame for N balls, plus 0x34 bytes of heap allocation per ball per frame.
Bottleneck 2: O(N²) Mass Despawn
Function: AthenaList_Remove (0x453690)
When balls fall off the arena, they're removed from the AthenaList. The remove function does:
malloc(count * 4)— allocate a new array- Copy all entries except the removed one
free(old_array)- Decrement 0x20 (32) iterator indices
This is O(N) per removal. When M balls fall simultaneously: O(M × N) total.
Bottleneck 3: Heavy Respawn Scan
Function: Ball_FindClosestRespawnPoint (0x405190)
Scans ALL spawn points in Scene+0x1518 AthenaList. For each spawn point:
stricmp/strstrstring comparisons- Distance calculation (3D magnitude)
- When a candidate is found: calls
Mesh_FindClosestCollision(0x465D90) which builds a full spatial tree from scratch (AthenaList_Init → CollisionMesh_ctor → SpatialTree_ctor → CollisionMesh_AddTriangle → Ball_AdvancePositionOrCollision → full cleanup)
Cost: O(spawn_points) per respawn, with a heavy collision mesh build per candidate.
Bottleneck 4: Per-Add Heap Realloc
Function: AthenaList_Append (0x453780)
Does realloc(ptr, (count+1)*4) PER ADD. O(N) heap churn per spawn, causes fragmentation.
Bottleneck 5: Iterator Limit
Function: AthenaList_NextIndex (0x4532B0)
Wraps at 0xFF (255). Hard limit on concurrent nested iterations — if exceeded, iterators corrupt each other's indices.
Solution: CE AutoAssembler Script
Four hooks that cap work per frame:
| Hook | Address | Function | Effect |
|---|---|---|---|
| 1 | 0x41B540 | Scene_UpdateBallsAndState entry | Reset per-frame counters, store ball count |
| 2 | 0x41B58A | First loop vtable[4] call | Skip non-player balls when count > cap |
| 3 | 0x41B62F | Second loop vtable[4] call | Same skip for secondary entity list |
| 4 | 0x405190 | Ball_FindClosestRespawnPoint entry | Throttle respawns to N per frame |
Skip Logic
- Player balls (ball+0x18 != -1) always get full physics
- Non-player balls use hash-based rotation:
(ball_ptr >> 4) XOR frame_counter - Each frame,
MAX_BALLS_PER_FRAME / total_countfraction of non-player balls get processed - All balls get processed within
ceil(count / MAX)frames - At 60 FPS with 100 balls and MAX=18: each ball updates every ~6 frames (10 Hz)
Performance Impact
| Ball Count | Without Fix | With Fix (MAX=18) | Improvement |
|---|---|---|---|
| 50 | ~50 balls × 50 collisions = 2500 ops | ~18 × 50 = 900 ops | 2.8x |
| 100 | ~100 × 100 = 10000 ops | ~18 × 100 = 1800 ops | 5.5x |
| 200 | ~200 × 200 = 40000 ops | ~18 × 200 = 3600 ops | 11x |
Adjustable Parameters (CE Address List)
MAX_BALLS_PER_FRAME(default 18): Max balls with full physics per frameMAX_RESPAWNS_PER_FRAME(default 3): Max respawns per frameBALL_COUNT: Current ball count (read-only, for monitoring)
Usage
- Open Cheat Engine, attach to Hamsterball.exe
- File → Load → select
EntityPerformanceFix.CEA - Enable the script (check the box)
- Add
MAX_BALLS_PER_FRAMEandMAX_RESPAWNS_PER_FRAMEto your address list to adjust at runtime
Tuning Guide
- Still lagging? Decrease
MAX_BALLS_PER_FRAMEto 10-12 - Balls too jerky? Increase
MAX_BALLS_PER_FRAMEto 24-30 - Respawns too slow? Increase
MAX_RESPAWNS_PER_FRAMEto 5-8 - Respawn storms still lag? Decrease
MAX_RESPAWNS_PER_FRAMEto 1-2
🔗 Related Documents
EntityLimitFixer v5
EntityLimitFixer v5
Prevents freezes AND crashes when spawning many entities (8-balls, clones) in arenas.
What changed from v4
v4 still froze because it only patched:
- operator_new (crash fix)
- AI loops (partial — only 8-balls)
- Respawn throttle (too aggressive — every 3rd frame)
v5 fixes the freeze at the source by patching the THREE compounding per-frame costs:
The Freeze Root Cause (traced via GhidraMCP)
Per frame at 30 balls (10 fallen clones, 16 respawn points):
1. Ball_FindClosestRespawnPoint (called every frame per fallen clone):
→ iterates ALL 16 respawn points
→ for EACH: calls Mesh_FindClosestCollision at 0x405C46
(builds SpatialTree + CollisionMesh + traverses geometry + frees)
→ 10 × 16 = 160 spatial tree builds per frame
2. Ball_Update (called per ball per frame):
→ calls Mesh_FindClosestCollision at 0x40651F (floor collision)
→ calls Mesh_FindClosestCollision at 0x407557 (wall collision)
→ 20 × 2 = 40 spatial tree builds per frame
3. Ball_AI_ChaseNearest (called per 8-ball per frame):
→ two O(N) loops scanning ALL balls = O(N²) total
→ 30 × 30 = 900 iterations per frame
TOTAL: ~200 spatial tree builds + thousands of allocs per frame
→ game thread takes >16ms → FREEZE
v5 Patches (all verified via GhidraMCP disassembly)
| # | Address | What | How |
|---|---|---|---|
| 1 | 0x4BA58D | operator_new crash | Replace CALL CRT_ThrowBadAlloc with XOR EAX,EAX; POP ESI; RET — return NULL instead of throwing |
| 2a | 0x40651F | Mesh_FindClosestCollision #1 in Ball_Update | Cave: if ball count > MAX_BALLS, skip call and write 99999 (no collision) |
| 2b | 0x407557 | Mesh_FindClosestCollision #2 in Ball_Update | Same cave pattern — skip when too many balls |
| 3 | 0x405C46 | Mesh_FindClosestCollision in RespawnPoint | NOP the CALL — pick nearest respawn by distance without collision check |
| 4a | 0x4083D9 | AI Loop 1 (near ball bonus) | Cave: skip O(N²) loop when count > MAX_BALLS |
| 4b | 0x408548 | AI Loop 2 (find nearest target) | Cave: skip O(N²) loop when count > MAX_BALLS |
| 5 | 0x405190 | Ball_FindClosestRespawnPoint | Cave: throttle to every 60th frame (was 3rd in v4) |
Key insight
Mesh_FindClosestCollision (0x465D90) is the most expensive per-ball operation — it builds a full SpatialTree from level geometry, traverses it for collision, then frees everything. Called 2× per ball in Ball_Update + up to 16× per fallen clone in RespawnPoint search = the main freeze cause.
v5 skips these calls when ball count exceeds MAX_BALLS, and NOPs the respawn-point collision check entirely (the ball just teleports to the nearest point without path-checking — fine for gameplay).
Usage
- Rename
bass.dll→bass_real.dllin your Hamsterball folder - Copy this
bass.dllinto the game folder - Launch the game
The mod waits 5 seconds after DLL load, verifies all byte signatures match, then applies patches.
Configuration
Change MAX_BALLS at top of source and recompile:
20= conservative (very stable)30= balanced (default)50= aggressive99= max (won't crash, may stutter at very high counts)
Change RESPAWN_THROTTLE for respawn frequency:
60= once per second at 60fps (default)30= twice per second120= every 2 seconds (very stable but slow respawns)
Build
i686-w64-mingw32-gcc -shared -o bass.dll bass_proxy.c \
-lwinmm -Wl,--enable-stdcall-fixup \
-O2 -static -static-libgcc -Wl,--add-stdcall-alias
🔗 Related Documents
Event Plane System
Hamsterball Event Plane System
Overview
Event planes are invisible collision meshes in Hamsterball levels. They use the E: name prefix in the .COL (collision) binary file. When the ball enters an event plane's collision volume, the game dispatches behavior based on the name string (e.g., E:JUMP, E:LIMIT, E:NODIZZY<TIME>300</TIME>).
This document covers:
- How event planes are loaded from MESHWORLD/.COL files
- How they're stored in memory (MeshBuffer struct)
- How collision detection finds them
- How the 3-tier dispatch system routes events by name
- Every known event type with exact addresses, parameters, and Ball/Scene offsets
1. Loading Pipeline
Source: Level_LoadCollision (0x00465260)
The .COL file is loaded alongside the .MESHWORLD visual mesh. The collision loader reads:
Header: 24 bytes → MeshWorld+0x45C (transform/flags)
int32: sublevel_count
if < 1 → single-mesh mode (read objects below)
else → multi-level mode (recursive sub-load)
Per collision object (single-mesh mode):
CreateMeshBuffer(0x874)— allocate a MeshBuffer (2148 bytes)- Append to
MeshWorld->object_list(+0x2C) - Read
int32 name_lengththenchar[name_length]— the name string - Prefix detection (case-insensitive
strnicmp):"N:"prefix →buf->interactive = 1(offset +0x85D) — visible + collidable"E:"prefix →buf->interactive = 1(+0x85D) ANDbuf->no_render = 1(+0x863) — invisible event trigger
- Read
int32 face_count - For each face: allocate 0x60 bytes, read 9 floats (v0 xyz, v1 xyz, v2 xyz), compute face normal via cross product, store per-vertex normals (flat shading), append to object's face list
Key Point: E: objects are NEVER rendered
The no_render flag (+0x863) causes Scene_RenderAllObjects (0x45E0E0) to skip them entirely. They exist only in the collision mesh for trigger detection.
2. MeshBuffer Struct (Collision Object) — 0x874 bytes
| Offset | Size | Field | Description |
|---|---|---|---|
| +0x000 | 4 | vtable* | Virtual table pointer |
| +0x00C | 0x418 | AthenaList | Face list (triangle faces) |
| +0x217 | 1 | byte | render_flag (= 0 for collision objects) |
| +0x85D | 1 | byte | interactive_flag (1 for N: and E: prefixes) |
| +0x863 | 1 | byte | no_render_flag (1 for E: prefix only) |
| +0x864 | 4 | char** | Name string pointer (e.g., "E:JUMP") |
CollisionFace Layout (0x60 bytes per triangle)
| Offset | Field |
|---|---|
| +0x00 | float v0.x, v0.y, v0.z |
| +0x0C | float normal.x, normal.y, normal.z (flat shading) |
| +0x18 | float v1.x, v1.y, v1.z |
| +0x24 | float normal.x, normal.y, normal.z |
| +0x30 | float v2.x, v2.y, v2.z |
| +0x3C | float normal.x, normal.y, normal.z |
3. Collision Detection → Event Name Retrieval
When a collision is detected, the handler receives a collObj pointer (2-element array of pointers). The event name is retrieved as:
char *eventName = *(char **)(collObj[1] + 0x864); // name string from MeshBuffer
This is the exact same string that was loaded from the .COL file. The dispatch code then does stricmp/strnicmp comparisons on this string to determine which behavior to trigger.
4. Two-Tier Dispatch System
Collision events are dispatched through a 2-tier handler chain. Level and Arena handlers are parallel, not chained — they never call each other. Both delegate to the shared base handler (DispatchCollisionEvents) as the final step. The scene's vtable determines which top-level handler runs.
Note: Ball_AdvancePositionOrCollision (0x4564C0) handles only geometric collision detection (velocity integration, mesh intersection via CollisionLevel->vtable[0x1C], max-speed clamping). It does NOT dispatch event-name-based collision events. Event dispatch is triggered from the ball update chain (ball->vtable[0x10], called by Scene_UpdateBallsAndState).
Tier 1a: TowerCollisionEvents (0x0040DCD0) — Level Events
Signature: void __thiscall TowerCollisionEvents(Scene *scene, Ball *ball, Collider *collider)
Handles level-specific events (catapults, trapdoors, maces, bite damage), then calls DispatchCollisionEvents.
Tier 1b: ExpertCollisionEvents (0x0040E6A0) — Arena Events
Signature: void __thiscall ExpertCollisionEvents(Scene *scene, Ball *ball, Collider *collider)
Handles arena/rumble events (hammers, saw blades, judges, bells), then calls DispatchCollisionEvents.
Base Tier: DispatchCollisionEvents (0x0040C5D0) — Shared Base Handler
Signature: void __thiscall DispatchCollisionEvents(void *this, int *ball, int *collObj)
Handles ALL common events. This is always called last regardless of level type.
5. Complete Event Reference
5a. Base Handler Events — DispatchCollisionEvents (0x0040C5D0)
| Event String | Match Type | Condition | Effect | Ball/Scene Offsets |
|---|---|---|---|---|
N:SECRET |
__strnicmp 8 chars |
— | Mark rotator as triggered | *collObj + 0x47C |
N:UNLOCKSECRET |
__strnicmp 14 chars |
— | Check arena unlock | — |
E:NODIZZY |
__strnicmp 9 chars |
— | Anti-dizzy zone. Parses <TIME>value</TIME> tags via MWParser_ReadTag. Duration passed to Ball_DizzyImmunity |
— |
E:SAFESWITCH |
__strnicmp 12 chars (0xC) |
— | Switch state. If no ( in name: clear ball+0x30B=0. If ( found: strcpy(ball+0xC2C, paren_content) |
ball+0x30B, ball+0xC2C |
E:LIMIT |
__stricmp exact |
— | Arena finish line. Clear velocity flag, set limit-hit | ball+0x1DA=0, ball+0x2E9=1, board+0x47B4-47C0 |
E:BREAK |
__stricmp exact |
— | Call ball bounce callback | ball->vtable[0x20]() |
E:JUMP |
__stricmp exact |
impactCounter < 1 |
Bounce pad. Play 3D jump sound, set cooldown=10, upward force=0.025, freeze=10 frames, +200 score | ball+0x1F7=10, ball+0xA7=0.025f, ball+0xA8=1, ball+0x202=10 |
E:ACTION |
__strnicmp 8 chars |
— | Parse XML tags: <ONCE>TRUE</ONCE> = one-time trigger (track in ball+0xCB list), <SCORE>500</SCORE> = award points with difficulty modifier |
ball+0xCB (once list) |
E:TRAJECTORY |
__strnicmp 12 chars |
— | Parse <X>, <Y>, <Z> tags. Set ball collision mesh direction |
mesh+0xCA4/CA8/CAC |
N:NOCONTROL |
__stricmp exact |
— | Disable ball input for 10 frames | ball+0x202=10 |
N:WATER |
__stricmp exact |
— | Set water flag, start 10-frame timer | ball+0x2D5=1, ball+0xB6=10 |
N:TARPIT |
__stricmp exact |
first entry only | Play tar sound. Set in-tar flag, clear velocity | ball+0xB3=1, ball+0x1DA=0 |
E:DROPIN |
__stricmp(eventName+2, "DROPIN") |
dist > threshold && dropinCounter < 1 |
Play dropin sound, set cooldown 50 frames, +200 score | ball dropinCounter=50 |
E:PIPEBONK |
__stricmp(eventName+2, "PIPEBONK") |
pipebonkCounter < 1 |
Play random pipe sound (3 variants), set cooldown 10 frames, +100 score | ball pipebonkCounter=10 |
E:POPOUT |
__stricmp(eventName+2, "POPOUT") |
popoutCounter < 1 |
Play popout sound, set cooldown 50 frames, +100 score | ball popoutCounter=50 |
N:GOAL |
strncmp 7 chars |
!ball.finished && ball.active |
Set goalReached=1, play "Goal!" music, mark player finished | — |
N:MOUSETRAP |
strncmp 12 chars |
— | RNG seed, deflect ball direction × trap speed | — |
Note on E:DROPIN, E:PIPEBONK, E:POPOUT: These three events use eventName+2 comparison, skipping the "E:" prefix. The name in the .COL file is still "E:PIPEBONK" etc.
5b. Level Handler Events — TowerCollisionEvents (0x0040DCD0)
| Event String | Match | Condition | Effect | Scene Offsets |
|---|---|---|---|---|
E:CATAPULTBOTTOM |
stricmp exact |
ball.cooldown < 1 |
Set cooldown=1000, find matching catapult in list, store ball pointer, launch catapult, play sound | scene+0x43B8 (catapult list), catapult+0x10EC (ball ptr) |
E:OPENSESAME |
stricmp exact |
— | Open first trapdoor | scene+0x4BEC |
N:TRAPDOOR |
stricmp exact |
— | Activate matching trapdoor(s) | scene+0x47D0 |
E:BITE |
stricmp exact |
— | Set damage timer=0, damage amount=25.0 | scene+0x43A8=0, scene+0x43A0=25.0 |
E:MACETRIGGER |
stricmp exact |
— | Set all maces active | mace list+0x5000, mace+0x10F0=1 |
N:MACE |
stricmp exact |
mace active & speed ≠ 80.0 | Call BounceForce on ball | ball->vtable[0x20]() |
5c. Arena Handler Events — ExpertCollisionEvents (0x0040E6A0)
| Event String | Match | Condition | Effect | Scene Offsets |
|---|---|---|---|---|
E:CALLHAMMER |
stricmp exact |
tournament only | Create hammer popup | scene+0x436C |
E:HAMMERCHASE |
stricmp exact |
tournament only | Start hammer chase | scene+0x436C |
E:ALERTSAW1/2 |
stricmp exact |
tournament only | Pre-activate saw blade | scene+0x4370/0x4374 |
E:ACTIVATESAW1/2 |
stricmp exact |
tournament only | Full activate saw blade | scene+0x4370/0x4374 |
E:ALERTJUDGES |
stricmp exact |
— | Reset all judge objects | scene+0x4FC8 |
E:SCORE<n> |
strnicmp 7 chars |
— | Parse number suffix, set time on score displays | — |
E:JUMP |
stricmp exact |
cooldown < 1 | Duplicate of base handler: play sound, bounce, +200 score | ball+0x1F7=10, ball+0xA7, ball+0xA8 |
E:BELL |
strnicmp 6 chars |
— | Activate bell, +500 bonus time if not racing, show "EXTRA TIME:" popup | scene+0x4FD4 |
6. Tag Parsing System (MWParser)
Several events embed XML-style parameters in the name string. These are parsed by MWParser_ReadTag():
| Event | Tag Format | Parameters |
|---|---|---|
E:NODIZZY |
<TIME>value</TIME> |
duration in frames |
E:ACTION |
<ONCE>TRUE</ONCE> <SCORE>500</SCORE> |
once-only trigger, score points |
E:TRAJECTORY |
<X>value</X> <Y>value</Y> <Z>value</Z> |
direction vector components |
BADBALL |
<CHASE>value</CHASE> <HOME> <SIZE>value</SIZE> <SPINDISTANCE>value</SPINDISTANCE> |
AI ball behavior tags |
MWParser_ReadTag returns:
struct MWTag {
void* release; // +0x00: vtable with dtor
char* tag_name; // +0x04: tag key (e.g., "TIME", "CHASE")
char* tag_value; // +0x08: tag value as string (e.g., "300", "25.0")
};
SAFESWITCH uses a different pattern — parenthesized data: E:SAFESWITCH(data) → strchr(eventName, '(') extracts the content.
7. N: vs E: Prefix Distinction
| Prefix | interactive (+0x85D) |
no_render (+0x863) |
Rendered | Collidable | Examples |
|---|---|---|---|---|---|
| N: | 1 | 0 | Yes | Yes | N:WALL, N:WATER, N:GOAL, N:TRAPDOOR |
| E: | 1 | 1 | No | Yes | E:JUMP, E:LIMIT, E:NODIZZY, E:PIPEBONK |
| (none) | 0 | 0 | Yes | No | Regular visual geometry |
- N: objects are visible geometry that also trigger gameplay events (walls, water zones, goal lines)
- E: objects are invisible trigger volumes (jump pads, boundaries, switches)
- Unprefixed objects are visual-only, non-interactive geometry
8. MESHWORLD Section 3 Object System (Visual Spawners)
Section 3 of the meshworld format contains named reference objects used for spawning game objects (not collision events). These use different prefixes:
- Access path:
Scene+0x8AC → MeshWorld* → MeshWorld+0x480 → section_root - Object array:
section_root+0xCA0 - Object count:
section_root+0x898 - Per-object layout:
- +0x00:
char* name(e.g., "BADBALL25 ", "START0-0", "E:JUMP") - +0x04:
float x, y, z(position) - +0x14:
float rot_y(rotation)
- +0x00:
The CreateLevelObjects factory (0x4121D0) scans these names with strnicmp to instantiate game objects:
| Name Prefix | Object | Ctor Size | Scene Offset |
|---|---|---|---|
BRIDGE |
Bridge mesh config | — | +0x436C/4370 |
TIPPER |
Tipper | 0x1104 | +0x2578 |
BONK |
Bonk | 0x1200 | +0x540C |
BBRIDGE1/2 |
BreakBridge | 0x1100 | +0x5418/541C |
POPCYLINDER |
PopCylinder | 0x10E8 | +0x5428 |
BLOCKDAWG1/2 |
Blockdawg | 0x1154 | +0x2578 |
CATAPULT |
Catapult | 0x1108 | +0x584C |
GLUEBIE |
Gluebie | 0x110C | +0x6080 |
BADBALL<tags> |
BadBall (AI) | 0xC98 | — |
9. Adding Custom Event Plane Types
To add a new event type to the game (e.g., E:MYNEWEVENT):
In the .COL file:
- Name your collision object
"E:MYNEWEVENT"— theE:prefix automatically setsinteractive=1, no_render=1 - Append tag parameters if needed:
"E:MYNEWEVENT<DURATION>100</DURATION>" - The name string is stored verbatim at MeshBuffer+0x864 and passed to the collision dispatcher
In the collision handler:
- Add a
stricmp/strnicmpcheck in the appropriate handler:TowerCollisionEvents(0x40DCD0) for level-specific eventsExpertCollisionEvents(0x40E6A0) for arena-specific eventsDispatchCollisionEvents(0x40C5D0) for universal events
- The event name is read from
*(char **)(collObj[1] + 0x864) - Use
strnicmpfor prefix matching (allows suffix data like tags) - Use
stricmpfor exact matching (no suffix data needed) - For tag parameters, use
MWParser_ReadTag()to parse XML-style<TAG>value</TAG>syntax - For parenthesized data, use
strchr(eventName, '(')to extract(data)
Key ball offsets for writing effects:
| Offset | Type | Name | Description |
|---|---|---|---|
| +0xA7 | float | vert_velocity | Vertical force (E:JUMP uses 0.025) |
| +0xA8 | byte | vert_velocity_on | Vertical velocity enable flag |
| +0xB3 | byte | in_tar | In tar zone flag |
| +0xB6 | int | water_timer | Water/zone effect timer (frames) |
| +0xCB | AthenaList | once_list | One-time trigger records (E:ACTION) |
| +0x1DA | byte | velocity_flag | Velocity/direction clear |
| +0x1F7 | byte | impact_counter | Jump/bounce cooldown timer |
| +0x202 | int | freeze_counter | Input freeze timer |
| +0x2D5 | byte | in_water | In water zone flag |
| +0x2E9 | byte | limit_hit | Finish line reached flag (⚠ sticky — never cleared in Ball_Update, NOT ground contact) |
| +0x30B | byte | safe_switch | Switch state (0 = clear) |
| +0xC2C | char[?] | safe_switch_data | Switch data buffer |
| +0xCA4 | Vec3 | collision_direction | Trajectory direction vector |
Key scene offsets:
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x43A0 | float | damage_amount | Bite damage value (25.0) |
| +0x43A8 | int | damage_timer | Damage effect timer |
| +0x43B8 | AthenaList | catapult_list | Catapult objects |
| +0x47D0 | AthenaList | door_list | Trapdoor objects |
| +0x4BEC | AthenaList | first_door | First trapdoor reference |
| +0x4FD4 | Bell* | bell_obj | Bell object |
| +0x5000 | AthenaList | mace_list | Mace objects |
| +0x540C | Bonk* | bonk_ref | Bonk object reference |
10. Data Flow Summary
.COL Binary File
│
├─ Level_LoadCollision (0x465260)
│ ├─ Read object name string (e.g., "E:JUMP")
│ ├─ Detect "E:" prefix → set interactive=1, no_render=1
│ ├─ Store name at MeshBuffer+0x864
│ └─ Build collision face list (triangles + normals)
│
├─ Ball Physics Update
│ └─ Mesh_FindClosestCollision — DDA ray traversal
│ └─ Returns collObj pointing to MeshBuffer
│
├─ Collision Handler Dispatch (2-tier, vtable-driven)
│ ├─ TowerCollisionEvents (0x40DCD0) — Tower board
│ │ └─ Level-specific events first (CATAPULTBOTTOM, OPENSESAME, etc.)
│ │ └─ delegates to DispatchCollisionEvents
│ ├─ ExpertCollisionEvents (0x40E6A0) — arenas
│ │ └─ Arena events first (CALLHAMMER, SAW, BELL, etc.)
│ │ └─ delegates to DispatchCollisionEvents
│ └─ DispatchCollisionEvents (0x40C5D0) — shared base, always called last
│ └─ eventName = *(collObj[1] + 0x864)
│ └─ stricmp/strnicmp dispatch to specific handlers
│
└─ Scene_RenderAllObjects (0x45E0E0)
└─ Skip objects with no_render flag (+0x863)
11. E:BLACKOUT — Not Found
E:BLACKOUT does not exist in any decompiled code. No string matching "blackout", "BLACKOUT", "BlackOut", or "black_out" was found. This event type either:
- Does not exist in the original game (possibly a mod/community addition)
- Uses a different internal name
- Is handled in an undiscovered code path
If you want to implement E:BLACKOUT for custom levels, add it as a new stricmp case in DispatchCollisionEvents and handle it with whatever effect you want (e.g., fade screen to black, toggle visibility).
🔗 Related Documents
Expert Race Objects
Expert Race Objects: Complete Reverse Engineering
Overview
Expert Race (Level5) contains 6 unique object types. All can be spawned globally.
Objects
| Object | String | String Addr | Alloc Size | Constructor | Ret | Vtable |
|---|---|---|---|---|---|---|
| BONK (Hammer) | "BONK" | 0x4CFA4C | 0x1200 | 0x438850 | 0x10 | 0x4D5120 |
| FAN | "FAN" | 0x4CFA48 | 0x1188 | 0x438C20 | 0x14 | 0x4D5180 |
| SAWBLADE | "SAWBLADE" | 0x4CFA28 | 0x111C | 0x434660 | 0x10 | 0x4D5240 |
| BRIDGE | "BRIDGE" | 0x4CF678 | 0x10FC | 0x4396F0 | 0x14 | 0x4D51E0 |
| JUDGE | "JUDGE" | 0x4CFA14 | 0x1100 | 0x43A150 | 0x10 | 0x4D52B8 |
| BELL | "BELL" | 0x4CFA0C | 0x10E8 | 0x434D70 | 0x10 | 0x4D5330 |
Factory Function
Arena factory: CreateExpertLevelObjects @ 0x40E250 (ret 0x10)
- Handles ALL 6 object types
- Called via Board vtable[33] at
0x40E250 - Also handles post-creation events (E:CALLHAMMER, E:HAMMERCHASE, E:ALERTSAW1/2, etc.)
Race factory: @ 0x414BD0 (ret 0x10)
- Only handles FAN
- Falls through to
FACTORY_RACE_BASE(0x4133E0) for unknown refs - Appends FAN to BOTH Board+0x2578 (general) AND Board+0x47E0
Constructor Calling Conventions
BONK (Hammer) — 4 stack params, ret 0x10
ecx = this (alloc 0x1200)
push Board ; param_1
sub esp, 0xC ; pos XYZ (3 floats from refEntry+4)
call 0x438850 ; ret 0x10
- Reads Board+0x878 → App → App+0x174 (D3D device)
- Calls Stands_ctor(0x461740) with D3D device — creates geometry internally
- Sets vtable 0x4D5120
- Stores Board at obj+0x10D0
- After factory: stored at Board+0x436C, collision sub-object at obj+0x10F8
FAN — 5 stack params, ret 0x14
ecx = this (alloc 0x1188)
push refEntry[0x14] ; param_5 (ext_flag integer, NOT mesh)
sub esp, 0xC ; pos XYZ
push Board ; param_1
call 0x438C20 ; ret 0x14
- Reads Board+0x878 → App → App+0x174 (D3D device)
- Calls Stands_ctor(0x461740) with D3D device
- Sets vtable 0x4D5180
- Sets obj+0x10E8=0.5, obj+0x10EE=1, obj+0x10F0=500.0
- Factory post-checks: "SLOW" → obj+0x10EC=1, "SUPER" → obj+0x10ED=1, "UP" → call 0x434580
- 5th param (ext_flag) is read by ctor but only as a float for position adjustment
SAWBLADE — 4 stack params, ret 0x10
ecx = this (alloc 0x111C)
push Board ; param_1
sub esp, 0xC ; pos XYZ
call 0x434660 ; ret 0x10
- Reads Board+0x878 → App → App+0x174 (D3D device)
- Sets vtable 0x4D5240
- After factory: "1" → stored at Board+0x4370, call 0x434AB0(obj, push 1)
- After factory: "2" → stored at Board+0x4374, call 0x434AB0(obj, push 2)
BRIDGE — 5 stack params, ret 0x14
ecx = this (alloc 0x10FC)
push refEntry[0x14] ; param_5 (ext_flag)
sub esp, 0xC ; pos XYZ
push Board ; param_1
call 0x4396F0 ; ret 0x14
- READS Board+0x4378 (bridge mesh) — calls Stands_ctor(0x462850) with it
- Sets vtable 0x4D51E0
- After factory: "1" → Append(Board+0x4380, obj), "2" → Append(Board+0x4798, obj)
- After factory: "NEG" → obj+0x10F8 = -1.0f (0xBF800000)
JUDGE — 4 stack params, ret 0x10
ecx = this (alloc 0x1100)
push Board ; param_1
sub esp, 0xC ; pos XYZ
call 0x43A150 ; ret 0x10
- Reads Board+0x878 → App → App+0x174 (D3D device)
- Sets vtable 0x4D52B8
- After factory: Append(Board+0x4BBC, obj)
BELL — 4 stack params, ret 0x10
ecx = this (alloc 0x10E8)
push Board ; param_1
sub esp, 0xC ; pos XYZ
call 0x434D70 ; ret 0x10
- Reads Board+0x878 → App → App+0x174 (D3D device)
- Calls Stands_ctor(0x461740) with D3D device
- Sets vtable 0x4D5330
- After factory: stored at Board+0x4FD4, Append(Board+0x2578, obj)
Mesh Loading
LevelBoard_Expert_ctor @ 0x41EA40 loads:
Board+0x4378=MeshWorld_ctor(0x10D0, App+0x174, "Levels\Level5-Bridge")@0x4D0ABCBoard+0x437C=CollisionLevel_ctorWithLevel(0x10D0, Board+0x4378)@0x465080Board+0x4BB0/4BB4/4BB8= 3× hammyjudge objects (0x18 bytes, ctor 0x471C20, mesh "meshes\hammyjudge")Board+0x4344= "Fight!" string ptr (0x4D0AA0)
Pre-loaded by resource loader (0x4298C0): NONE — all Expert meshes are level-specific.
AthenaList Initialization
LevelBoard_Expert_ctor initializes:
Board+0x4380(saw1 bridge list)Board+0x4798(saw2 bridge list)Board+0x4BBC(judge list)
On non-Expert levels, these are NOT initialized → must call AthenaList_Init(0x453210).
Board+0x2578 (general list) is initialized by Board_ctor on ALL levels.
Collision Events
TowerCollisionEvents @ 0x40E6A0 (Expert Board vtable[29] at 0x74):
| Event | String Addr | Handler | Action |
|---|---|---|---|
| E:CALLHAMMER | 0x4CFAC8 | 0x438B30 | Calls Board+0x436C (BONK) to chase ball |
| E:HAMMERCHASE | 0x4CFAB8 | 0x438BB0 | Activates hammer chase on Board+0x436C |
| E:ALERTSAW1 | 0x4CFAAC | 0x434770 | Alerts Board+0x4370 (SAW1) |
| E:ALERTSAW2 | 0x4CFAA0 | 0x434770 | Alerts Board+0x4374 (SAW2) |
| E:ACTIVATESAW1 | 0x4CFA90 | 0x434A50 | Activates Board+0x4370 (SAW1) |
| E:ACTIVATESAW2 | 0x4CFA80 | 0x434A50 | Activates Board+0x4374 (SAW2) |
| E:ALERTJUDGES | 0x4CFA70 | iterates Board+0x4BBC | Cycles through judge list |
| E:SCORE | 0x4CFA68 | creates 3D sound | Sets score, plays sound |
| E:JUMP | 0x4CF890 | creates 3D sound | Jump pad effect |
On non-Expert levels: Objects have physical collision (via internal CollisionLevel) but special events won't fire because the collision handler is level-specific.
Object Updates
Each object's vtable contains update/render functions:
- vtable[0x54] = render function (called each frame)
- vtable[0x58] = update function (called each frame)
- vtable[0x60] = collision response
Global Spawn Requirements
No mesh needed (create from D3D device):
- BONK, FAN, SAWBLADE, JUDGE, BELL
- Just need: alloc + Board + position
Mesh needed:
- BRIDGE: requires Board+0x4378 = MeshWorld("Levels\Level5-Bridge")
- Load via:
MeshWorld_ctor(0x10D0, App+0x174, "Levels\Level5-Bridge")@ 0x461510 - String at 0x4D0ABC
- Load via:
AthenaList_Init required:
- Board+0x4380 (for BRIDGE "1")
- Board+0x4798 (for BRIDGE "2")
- Board+0x4BBC (for JUDGE)
Post-spawn Board field writes:
- BONK → Board+0x436C
- SAWBLADE → Board+0x4370 (saw1) or Board+0x4374 (saw2)
- BELL → Board+0x4FD4
🔗 Related Documents
Factory Object System
Hamsterball Factory Object System — Comprehensive Analysis
Overview
The Hamsterball object factory system uses a dispatch pattern: each Board level loads
MeshWorld sub-meshes during construction (in the BoardLevel*_ctor), storing MeshWorld
pointers at specific Board offsets. Then, a per-level Create*Objects factory function is
called for each named object in the MESHWORLD file, using __strnicmp to match object
name prefixes and instantiate the appropriate game object with position/rotation data from
a param_block.
Key Constants
- Image base: 0x00400000
- Board (this/ecx) + 0x878 = App pointer
- App + 0x23C = difficulty enum (0 = easy, non-zero = hard)
- Board + 0x2578 = general active objects list (AthenaList)
- param_block layout: Position X/Y/Z at +4/+8/+0xC, Rotation X/Y/Z at +0x10/+0x14/+0x18
- MeshWorld allocation size: 0x10D0 bytes
- MeshNode allocation size: 0x18 bytes
- CollisionLevel allocation size: 0x10D0 bytes
1. Factory Function: CreateLevelObjects (0x004121D0)
Used by: Dizzy Race (Level3), Master Race, and other levels using Level3-style objects.
| Object Name | Match | Alloc Size | Constructor | Board Mesh Offset | Board Store Offset | AthenaList(s) | Difficulty Gate | Position Fields | Rotation Fields |
|---|---|---|---|---|---|---|---|---|---|
| BRIDGE | strnicmp "BRIDGE", 6 | N/A (no alloc) | None — configures existing mesh | +0x436C | +0x437C, +0x4380, +0x4384 (pos) | — | No | param_4+4/+8/+0xC | — |
| TIPPER | strnicmp "TIPPER", 6 | 0x1104 | Tipper_ctor | +0x4394 (mesh), +0x4398 (visual mesh) | — | +0x2578 | YES (App+0x23C != 0) | obj+0x10D8/+0x10DC/+0x10E0 | obj+0x10E4/+0x10E8/+0x10EC |
| TipperVisual | (sub-alloc) | 0x10D0 | TipperVisual_ctor | +0x4398 | obj+0x10D4 | — | — | — | — |
| BONK | strnicmp "BONK", 4 | 0x1200 | Bonk_ctor | — | +0x540C | +0x2578 | YES (App+0x23C != 0) | param_4+4/+8/+0xC | — |
| BBRIDGE1 | strnicmp "BBRIDGE1", 8 | 0x1100 | BreakBridge_ctor | +0x5410 | +0x5418 | +0x2578 | No | param_4+4/+8/+0xC | — |
| BBRIDGE2 | strnicmp "BBRIDGE2", 8 | 0x1100 | BreakBridge_ctor | +0x5414 | +0x541C | +0x2578 | No | param_4+4/+8/+0xC | — |
| POPCYLINDER | strnicmp "POPCYLINDER", 11 | 0x10E8 | PopCylinder_ctor | +0x5420 | — | +0x2578, +0x5428 | No | param_4+4/+8/+0xC | — |
| BLOCKDAWG1 | strnicmp "BLOCKDAWG1", 10 | 0x1154 | Blockdawg_ctor | +0x5840 | — | +0x2578 | YES (App+0x23C != 0) | param_4+4/+8/+0xC | — |
| BLOCKDAWG2 | strnicmp "BLOCKDAWG2", 10 | 0x1154 | Blockdawg_ctor | +0x5844 | — (sets obj+0x1152=1) | +0x2578 | YES (App+0x23C != 0) | param_4+4/+8/+0xC | — |
| CATAPULT | strnicmp "CATAPULT", 8 | 0x1108 | Catapult_ctor | +0x5848 | — (sets obj+0x1100=1) | +0x2578, +0x584C | No | obj+0x10D8/+0x10DC/+0x10E0 | — |
| GLUEBIE | strnicmp "GLUEBIE", 7 | 0x110C | Gluebie_ctor | +0x607C | — | +0x6080, +0x2578 | YES (App+0x23C != 0, inverted: skips if 0) | obj+0x10D4/+0x10D8/+0x10DC | — |
BLOCKDAWG Path Lookups
- BLOCKDAWG1:
Level_FindObjectByName(Board+0x8AC, "DAWGPATH1")→ path index passed to ctor - BLOCKDAWG2:
Level_FindObjectByName(Board+0x8AC, "DAWGPATH2")→ path index passed to ctor
2. Factory Function: CreateUpLevelObjects (0x004117B0)
Used by: Up Race (LevelUp).
| Object Name | Match | Alloc Size | Constructor | Board Mesh Offset | AthenaList(s) | Difficulty Gate | Position Fields | Notes |
|---|---|---|---|---|---|---|---|---|
| LIFTER | strnicmp "LIFTER", 6 | 0x10F4 | Rotator_ctor_sound | +0x4784 | +0x2578 | No | param_4+4/+8/+0xC | Parses _atol(param_1+6) for numeric arg |
| SPEEDCYLINDER | strnicmp "SPEEDCYLINDER", 13 | 0x150C | Pendulum_ctor | +0x4788 | +0x2578 | No | param_4+4/+8/+0xC | Uses __ftol2 for float→int conversion |
| TIMEBUTTON | strnicmp "TIMEBUTTON", 10 | 0x10E8 | Rotator_ctor_nosound | +0x478C | +0x2578 | No | param_4+4/+8/+0xC | Returns obj via param_2/param_3 |
3. Factory Function: CreateMechanicalObjects (0x00417FE0)
Used by: Impossible Race and other mechanical levels.
| Object Name | Match | Alloc Size | Constructor | Board Mesh Offset | AthenaList(s) | Difficulty Gate | Position Fields | Rotation Fields | Notes |
|---|---|---|---|---|---|---|---|---|---|
| LOOPER | strnicmp "LOOPER", 6 | 0x1500 | Looper_ctor | +0x436C | +0x2578 | No | param_4+4/+8/+0xC | — | — |
| GEAR | strnicmp "GEAR", 4 | 0x1514 | Gear_ctor | +0x4370 | +0x2578 | No | param_4+4/+8/+0xC (pos1) + param_4+0x10/+0x14/+0x18 (pos2) | Both position AND rotation from param_block | — |
| BIGGEAR | strnicmp "BIGGEAR", 7 | 0x1514 | Gear_ctor | +0x4374 | +0x2578 | No | param_4+4/+8/+0xC (pos1) + param_4+0x10/+0x14/+0x18 (pos2) | Sets obj+0x10F4=0x3F000000 (0.5f scale). Checks strstr("TOUCH")→obj+0x1510=1 | — |
| ROTATOR | strnicmp "ROTATOR", 7 | 0x1508 | Rotator_ctor | +0x4378 | +0x2578 | No | param_4+4/+8/+0xC | Sets obj+0x10E8=±1.0f (RNG_Rand direction). obj+0x10D4 = result | — |
| PENDULUM | strnicmp "PENDULUM", 8 | 0x1504 | Pendulum_ctor | +0x437C | +0x2578 | No | param_4+4/+8/+0xC | — | Returns obj via param_2/param_3 |
4. Factory Function: CreateExpertLevelObjects (0x0040E250)
Used by: Expert Race (Level5).
| Object Name | Match | Alloc Size | Constructor | Board Mesh Offset | Board Store Offset | AthenaList(s) | Difficulty Gate | Position Fields | Notes |
|---|---|---|---|---|---|---|---|---|---|
| BONK | strnicmp "BONK", 4 | 0x1200 | Bonk_ctor | — | +0x436C | +0x2578 | YES (App+0x23C != 0) | param_4+4/+8/+0xC | — |
| FAN | strnicmp "FAN", 3 (via PTR_DAT_0x4CFA48) | 0x1188 | TowerLevel_Ctor | — | — | +0x2578 | YES (App+0x23C != 0) | param_4+4/+8/+0xC + param_4+0x14 (extra float) | Checks strstr("SLOW")→obj+0x10EC=1, strstr("SUPER")→obj+0x10ED=1, strstr("UP")→Sound_InitChannels |
| SAWBLADE | strnicmp "SAWBLADE", 8 | 0x111C | Sawblade_Level_Ctor | — | — | +0x2578 | YES (App+0x23C != 0) | param_4+4/+8/+0xC | strstr("1")→Board+0x4370=Sawblade, Sawblade_SetBreakSound(1). strstr("2")→Board+0x4374=Sawblade, Sawblade_SetBreakSound(2) |
| BRIDGE (Expert) | strnicmp "BRIDGE", 6 | 0x10FC | Spinner_Level_ctor | — | — | +0x2578 (conditional) | No | param_4+4/+8/+0xC + param_4+0x14 | strstr("1")→AthenaList+0x4380. strstr("2")→AthenaList+0x4798. strstr("NEG")→obj+0x10F8=0xBF800000 (-1.0f) |
| JUDGE | strnicmp "JUDGE", 5 | 0x1100 | Gear_Level_ctor | — | — | +0x4BBC | No | param_4+4/+8/+0xC | — |
| BELL | strnicmp "BELL", 4 | 0x10E8 | Tipper_Level_Ctor | — | +0x4FD4 | +0x2578 | No | param_4+4/+8/+0xC | — |
5. Factory Function: CreateSpinny (0x004143D0)
Used by: Toob Race (Level8), Master Race, and general-purpose spinny levels.
| Object Name | Match | Alloc Size | Constructor | Board Mesh Offset | AthenaList(s) | Difficulty Gate | Position Fields | Notes |
|---|---|---|---|---|---|---|---|---|
| SPINNY | strnicmp "SPINNY", 6 | 0x1508 | Rotator_ctor | +0x47E0 | +0x2578 | No | param_4+4/+8/+0xC | Falls through to CreatePlatformOrStands if no match |
6. Factory Function: CreateLifter (0x00414A20)
Used by: Lifter levels (Level6, Impossible).
| Object Name | Match | Alloc Size | Constructor | Board Mesh Offset | AthenaList(s) | Difficulty Gate | Position Fields | Notes |
|---|---|---|---|---|---|---|---|---|
| LIFTER | strnicmp "LIFTER", 6 | 0x10F4 | Rotator_ctor_sound | +0x47E0 | +0x2578 | No | param_4+4/+8/+0xC | Parses _atol(param_1+6) for numeric arg. Falls through to CreatePlatformOrStands if no match |
7. Factory Function: Scene_CreateObject_Gear (0x00418760)
Used by: Impossible Race and other gear levels.
| Object Name | Match | Alloc Size | Constructor | Board Mesh Offset | AthenaList(s) | Difficulty Gate | Position Fields | Rotation Fields | Notes |
|---|---|---|---|---|---|---|---|---|---|
| GEAR | strnicmp "GEAR", 4 | 0x1514 | Gear_ctor | +0x47E0 | +0x2578 | No | param_4+4/+8/+0xC (pos1) + param_4+0x10/+0x14/+0x18 (pos2) | Both position AND rotation from param_block | Sets obj+0x10F4=0x40000000 (2.0f scale). Falls through to CreatePlatformOrStands |
8. Board Level Constructors — Mesh Loading & Board Offsets
LevelBoard_Dizzy_ctor (0x0041D060) — "Dizzy Race"
- Vtable: PTR_LevelBoard_Dizzy_dtor_004d0890
- AthenaLists: +0x4378, +0x4790
- Race data: "DIZZYRACE", Display: "Dizzy!", Race name: "DIZZY RACE"
- Gravity: (0, 1.0, 0)
| Mesh Path | VA (String) | Board Offset | Collision Offset | Object Type |
|---|---|---|---|---|
| Levels\Level3-Tipper | 0x4D07E8 | +0x436C | +0x4370 | TIPPER mesh |
| Levels\Level3-WaterWheel | 0x4D0794 | +0x4BA8 | +0x4BAC | WATERWHEEL (spinning floor) |
| Levels\Level3-Swirl | 0x4CFFE0 | +0x4BC4 | +0x4BC8 | SWIRL mesh |
| Levels\Level3-Gluebie | 0x4D0728 | +0x4374 | (no separate collision) | GLUEBIE mesh |
LevelBoard_Tower_ctor (0x0041E340) — "Tower Race"
- Vtable: PTR_LevelBoard_Tower_dtor_004d0a08
- AthenaLists: +0x43B8, +0x47D0, +0x4BE8, +0x5000
- Race data: "TOWERRACE", Display: "Happy Rush", Race name: "TOWER RACE"
- Gravity: (1.0, 0.5, 0)
| Mesh Path | VA (String) | Board Offset | Collision Offset | Object Type |
|---|---|---|---|---|
| Levels\Level4-Catapult | 0x4D09B8 | +0x436C | (none) | CATAPULT mesh |
| Levels\Level4-Drawbridge | 0x4D099C | +0x4370 | (none) | DRAWBRIDGE/BREAKBRIDGE mesh |
| Meshes\YellowLink | 0x4D0988 | +0x4374 | (MeshNode, 0x18 bytes) | YellowLink visual |
| Levels\Level4-Mace | 0x4D0974 | +0x4378 | (none) | MACE mesh |
| Levels\Level4-Windmill | 0x4D095C | +0x437C | (none) | WINDMILL mesh |
| Meshes\Chomper | 0x4D094C | +0x4390 | (MeshNode, 0x18 bytes) | CHOMPER visual mesh |
| Levels\Level4-Turret | 0x4D0932* | +0x43B4 | (none) | TURRET mesh |
*Note: String at 0x4D0932 has a prefix byte "pB" before "Levels\Level4-Turret" (likely Ghidra parsing artifact; actual string starts at the "L").
LevelBoard_Up_ctor (0x00420390) — "Up Race"
- Vtable: PTR_BoardLevel_Generic_dtor3_004d11a0
- AthenaLists: +0x436C
- Race data: "UPRACE", Display: "Up Race", Race name: "UP RACE"
- Gravity: (1.0, 0, 1.0)
| Mesh Path | VA (String) | Board Offset | Object Type |
|---|---|---|---|
| levels\levelup-lifter | 0x4D1160 | +0x4784 | LIFTER mesh |
| levels\levelup-speedcylinder | 0x4D1140 | +0x4788 | SPEEDCYLINDER mesh |
| levels\levelup-button | 0x4D1128 | +0x478C | TIMEBUTTON mesh |
LevelBoard_Expert_ctor (0x0041EA40) — "Expert Race"
- Vtable: PTR_LevelBoard_Expert_dtor_004d0b00
- AthenaLists: +0x4380, +0x4798, +0x4BBC
- Race data: "EXPERTRACE", Display: "Fight!", Race name: "EXPERT RACE"
- Gravity: (1.0, 0, 0)
| Mesh Path | VA (String) | Board Offset | Collision Offset | Object Type |
|---|---|---|---|---|
| Levels\Level5-Bridge | 0x4D0ABC | +0x4378 | +0x437C | BRIDGE/SPINNER mesh |
| meshes\hammyjudge | 0x4D0AA8 | +0x4BB0 | (MeshNode, 0x18 bytes) | JUDGE visual (1) |
| meshes\hammyjudge | 0x4D0AA8 | +0x4BB4 | (MeshNode, 0x18 bytes) | JUDGE visual (2) |
| meshes\hammyjudge | 0x4D0AA8 | +0x4BB8 | (MeshNode, 0x18 bytes) | JUDGE visual (3) |
LevelBoard_Toob_ctor (0x0041F4B0) — "Toob Race"
- Vtable: PTR_BoardLevel_Toob_dtor_004d0e78
- Vec3 array at +0x438C (8 elements of 0x418 each)
- Race data: "TOOBRACE", Display: "Rodenthood", Race name: "TOOB RACE"
- Gravity: (0.5, 0.5, 1.0)
| Mesh Path | VA (String) | Board Offset | Object Type |
|---|---|---|---|
| Levels\Level8-Spinny | 0x4D0E38 | +0x436C | SPINNY mesh |
| Levels\Level8-Saw | 0x4D0E24 | +0x4370 | SAW/SAWBLADE mesh |
| Levels\Level8-Fallout | 0x4D0E0C | +0x4374 | FALLOUT mesh |
| Levels\Level8-Blockdawg1 | 0x4D0DF0 | +0x4378 | BLOCKDAWG1 mesh |
| Levels\Level8-Blockdawg2 | 0x4D0DD4 | +0x437C | BLOCKDAWG2 mesh |
BoardLevel_Master_Ctor (0x004206D0) — "Master Race"
- Vtable: PTR_BoardLevel_Generic_dtor4_004d12b0
- Vec3 array at +0x439C (4 elements of 0x418 each)
- AthenaLists: +0x5428, +0x584C, +0x5C64, +0x6080
- Race data: "MASTERRACE", Display: "Master Theme", Race name: "MASTER RACE"
- Gravity: (0.5, 0.5, 0.5)
| Mesh Path | VA (String) | Board Offset | Collision Offset | Object Type |
|---|---|---|---|---|
| Levels\Level2-Bridge | 0x4D055C | +0x436C | +0x4370 | BRIDGE mesh |
| Levels\Level10-2PBridge | 0x4D127C | +0x4374 | +0x4378 | BBRIDGE/2P mesh |
| Levels\Level3-Tipper | 0x4D07E8 | +0x4394 | +0x4398 | TIPPER mesh + visual |
| Levels\Level10-Bridge1 | 0x4D1264 | +0x5410 | (none) | BBRIDGE1 mesh |
| Levels\Level10-Bridge2 | 0x4D124C | +0x5414 | (none) | BBRIDGE2 mesh |
| levels\level9-popcylinder1 | 0x4D0F5C | +0x5420 | (none) | POPCYLINDER1 mesh |
| levels\level9-popcylinder2 | 0x4D0F40 | +0x5424 | (none) | POPCYLINDER2 mesh |
| Levels\Level8-Blockdawg1 | 0x4D0DF0 | +0x5840 | (none) | BLOCKDAWG1 mesh |
| Levels\Level8-Blockdawg2 | 0x4D0DD4 | +0x5844 | (none) | BLOCKDAWG2 mesh |
| Levels\Level4-Catapult | 0x4D09B8 | +0x5848 | (none) | CATAPULT mesh |
| Levels\Level3-Gluebie | 0x4D0728 | +0x607C | (none) | GLUEBIE mesh |
ArenaBoard_Tower_ctor (0x004228C0) — "Tower Arena"
- Vtable: PTR_ArenaBoard_Odd_DeletingDtor_004d1740
- AthenaLists: +0x47E4, +0x4C00
- Race name: "TOWER ARENA", Display: "Happy Rush"
| Mesh Path | VA (String) | Board Offset | Object Type |
|---|---|---|---|
| Levels\Level4-Mace | 0x4D0974 | +0x47E0 | MACE mesh |
| Levels\Level4-Catapult | 0x4D09B8 | +0x4BFC | CATAPULT mesh |
| Levels\Level4-Turret | 0x4D0932 | +0x5018 | TURRET mesh |
9. Complete String Address Table (.rdata section)
Object Name Strings (for strnicmp matching)
| VA | File Offset | String |
|---|---|---|
| 0x4CF678 | 0xCF678 | BRIDGE |
| 0x4CF680 | 0xCF680 | GLUEBIE |
| 0x4CF688 | 0xCF688 | SWIRL |
| 0x4CF690 | 0xCF690 | WATERWHEEL |
| 0x4CF69C | 0xCF69C | TIPPER |
| 0x4CF818 | 0xCF818 | PIPEBONK |
| 0x4CF900 | 0xCF900 | N:BRIDGE |
| 0x4CF928 | 0xCF928 | N:SWIRL |
| 0x4CF930 | 0xCF930 | N:WHEELEMBED |
| 0x4CF940 | 0xCF940 | N:WATERWHEEL |
| 0x4CF960 | 0xCF960 | TURRET |
| 0x4CF968 | 0xCF968 | CHOMPER |
| 0x4CF970 | 0xCF970 | TRAPDOOR |
| 0x4CF97C | 0xCF97C | WINDMILL |
| 0x4CF988 | 0xCF988 | DRAWBRIDGE |
| 0x4CF994 | 0xCF994 | MACE |
| 0x4CF99C | 0xCF99C | CATAPULT |
| 0x4CF9A0 | 0xCF9A0 | CATAPULT (alt offset) |
| 0x4CFA0C | 0xCFA0C | BELL |
| 0x4CFA14 | 0xCFA14 | JUDGE |
| 0x4CFA28 | 0xCFA28 | SAWBLADE |
| 0x4CFA48 | 0xCFA48 | FAN |
| 0x4CFA4C | 0xCFA4C | BONK |
| 0x4CFB1C | 0xCFB1C | LIFTER |
| 0x4CFCC0 | 0xCFCC0 | BLOCKDAWG3 |
| 0x4CFCD8 | 0xCFCD8 | BLOCKDAWG2 |
| 0x4CFCF0 | 0xCFCF0 | BLOCKDAWG1 |
| 0x4CFD2C | 0xCFD2C | SPINNY |
| 0x4CFD4C | 0xCFD4C | N:SPINNY |
| 0x4CFDB8 | 0xCFDB8 | POPCYLINDER |
| 0x4CFE40 | 0xCFE40 | TIMEBUTTON |
| 0x4CFE4C | 0xCFE4C | SPEEDCYLINDER |
| 0x4CFEB4 | 0xCFEB4 | BBRIDGE2 |
| 0x4CFEC0 | 0xCFEC0 | BBRIDGE1 |
| 0x4CFF04 | 0xCFF04 | BONKBASH |
| 0x4CFF10 | 0xCFF10 | BONKPOPUP |
| 0x4CFF98 | 0xCFF98 | levels\arena-WarmUp |
| 0x4CFEF8 | 0xCFEF8 | N:SPINNER |
| 0x4D01C0 | 0xD01C0 | PENDULUM |
| 0x4D01CC | 0xD01CC | ROTATOR |
| 0x4D01DC | 0xD01DC | BIGGEAR |
| 0x4D01E4 | 0xD01E4 | GEAR |
| 0x4D01EC | 0xD01EC | LOOPER |
| 0x4D0144 | 0xD0144 | FLICKRING |
| 0x4CFF5C | 0xCFF5C | STANDS |
| 0x4CFF64 | 0xCFF64 | PLATFORM |
Mesh Path Strings
| VA | File Offset | String | Used By |
|---|---|---|---|
| 0x4CF8E0 | 0xCF8E0 | levels\level1 | Beginner Race |
| 0x4CF8F0 | 0xCF8F0 | levels\level2 | Intermediate Race |
| 0x4CF918 | 0xCF918 | levels\level3 | Dizzy Race (main mesh) |
| 0x4CF950 | 0xCF950 | levels\level4 | Tower Race (main mesh) |
| 0x4CFC1C | 0xCFC1C | levels\level7 | Wobbly Race (main mesh) |
| 0x4CFCA8 | 0xCFCA8 | levels\level8 | Toob Race (main mesh) |
| 0x4CFDA8 | 0xCFDA8 | levels\level9 | Sky Race (main mesh) |
| 0x4CFE30 | 0xCFE30 | levels\levelup | Up Race (main mesh) |
| 0x4CFEA4 | 0xCFEA4 | levels\level10 | Master/2P Race (main mesh) |
| 0x4D01A8 | 0xD01A8 | levels\levelimpossible | Impossible Race (main mesh) |
| 0x4D055C | 0xD055C | Levels\Level2-Bridge | Intermediate BRIDGE mesh |
| 0x4D07E8 | 0xD07E8 | Levels\Level3-Tipper | Dizzy TIPPER mesh |
| 0x4D0794 | 0xD0794 | Levels\Level3-WaterWheel | Dizzy WATERWHEEL (spinning floor) mesh |
| 0x4CFFE0 | 0xCFFE0 | Levels\Level3-Swirl | Dizzy SWIRL mesh |
| 0x4D0728 | 0xD0728 | Levels\Level3-Gluebie | Dizzy GLUEBIE mesh |
| 0x4D09B8 | 0xD09B8 | Levels\Level4-Catapult | Tower CATAPULT mesh |
| 0x4D099C | 0xD099C | Levels\Level4-Drawbridge | Tower DRAWBRIDGE mesh |
| 0x4D0974 | 0xD0974 | Levels\Level4-Mace | Tower MACE mesh |
| 0x4D095C | 0xD095C | Levels\Level4-Windmill | Tower WINDMILL mesh |
| 0x4D0932 | 0xD0932 | Levels\Level4-Turret | Tower TURRET mesh (note: prefixed with "pB" in raw) |
| 0x4D094C | 0xD094C | Meshes\Chomper | Tower CHOMPER visual mesh (MeshNode) |
| 0x4D0988 | 0xD0988 | Meshes\YellowLink | Tower YellowLink visual (MeshNode) |
| 0x4D33EC | 0xD33EC | Levels\Level4-Trapdoor2 | Tower TRAPDOOR2 mesh |
| 0x4D3404 | 0xD3404 | Levels\Level4-Trapdoor1 | Tower TRAPDOOR1 mesh |
| 0x4D0ABC | 0xD0ABC | Levels\Level5-Bridge | Expert BRIDGE/SPINNER mesh |
| 0x4D0AA8 | 0xD0AA8 | meshes\hammyjudge | Expert JUDGE visual (MeshNode, ×3) |
| 0x4D0E38 | 0xD0E38 | Levels\Level8-Spinny | Toob SPINNY mesh |
| 0x4D0E24 | 0xD0E24 | Levels\Level8-Saw | Toob SAWBLADE mesh |
| 0x4D0E0C | 0xD0E0C | Levels\Level8-Fallout | Toob FALLOUT mesh |
| 0x4D0DF0 | 0xD0DF0 | Levels\Level8-Blockdawg1 | Toob/Master BLOCKDAWG1 mesh |
| 0x4D0DD4 | 0xD0DD4 | Levels\Level8-Blockdawg2 | Toob/Master BLOCKDAWG2 mesh |
| 0x4D0F28 | 0xD0F28 | levels\level9-trapdoor | Sky TRAPDOOR mesh |
| 0x4D0F40 | 0xD0F40 | levels\level9-popcylinder2 | Sky/Master POPCYLINDER2 mesh |
| 0x4D0F5C | 0xD0F5C | levels\level9-popcylinder1 | Sky/Master POPCYLINDER1 mesh |
| 0x4D1160 | 0xD1160 | levels\levelup-lifter | Up LIFTER mesh |
| 0x4D1140 | 0xD1140 | levels\levelup-speedcylinder | Up SPEEDCYLINDER mesh |
| 0x4D1128 | 0xD1128 | levels\levelup-button | Up TIMEBUTTON mesh |
| 0x4D124C | 0xD124C | Levels\Level10-Bridge2 | Master BBRIDGE2 mesh |
| 0x4D1264 | 0xD1264 | Levels\Level10-Bridge1 | Master BBRIDGE1 mesh |
| 0x4D127C | 0xD127C | Levels\Level10-2PBridge | Master 2P BRIDGE mesh |
| 0x4D20DC | 0xD20DC | Levels\LevelImpossible-Pendulum | Impossible PENDULUM mesh |
| 0x4D20FC | 0xD20FC | Levels\LevelImpossible-Rotator | Impossible ROTATOR mesh |
| 0x4D211C | 0xD211C | Levels\LevelImpossible-BigGear | Impossible BIGGEAR mesh |
| 0x4D213C | 0xD213C | Levels\LevelImpossible-Gear | Impossible GEAR mesh |
| 0x4D2158 | 0xD2158 | Levels\LevelImpossible-Looper | Impossible LOOPER mesh |
| 0x4D3308 | 0xD3308 | Levels\Level6-Lifter | Impossible LIFTER mesh |
| 0x4D3390 | 0xD3390 | Meshes\sawblade | Expert SAWBLADE visual (MeshNode) |
| 0x4D3468 | 0xD3468 | Meshes\Bell | Expert BELL visual (MeshNode) |
| 0x4D5C10 | 0xD5C10 | levels\level5-bonk | Expert BONK mesh |
Sound Path Strings
| VA | File Offset | String |
|---|---|---|
| 0x4D2BC8 | 0xD2BC8 | sounds\breakbridge |
| 0x4D2C00 | 0xD2C00 | sounds\speedcylinder |
| 0x4D2D14 | 0xD2D14 | sounds\bell |
| 0x4D2DE8 | 0xD2DE8 | sounds\catapult |
| 0x4D2E54 | 0xD2E54 | sounds\bridgeslam |
| 0x4D2E68 | 0xD2E68 | sounds\gearclank |
10. Board Offset Summary (Consolidated)
| Board Offset | Type | Description | Used By |
|---|---|---|---|
| +0x2578 | AthenaList | Active game objects list (ALL objects) | All factories |
| +0x436C | MeshWorld* | Bridge/Tipper/Catapult/Spinny/Looper mesh (varies by level) | Dizzy, Tower, Expert, Master, Impossible |
| +0x4370 | CollisionLevel*/MeshWorld* | Collision for +0x436C / or secondary mesh | Dizzy, Tower, Expert, Master |
| +0x4374 | MeshWorld* | Gluebie mesh (Dizzy) / 2PBridge (Master) / Fallout (Toob) | Dizzy, Master, Toob |
| +0x4378 | AthenaList*/MeshWorld* | Dizzy: AthenaList / Tower: Mace / Expert: Bridge / Toob: Blockdawg1 | Dizzy, Tower, Expert, Toob |
| +0x437C | CollisionLevel*/MeshWorld* | Dizzy: collision / Tower: Windmill / Expert: collision / Toob: Blockdawg2 | Dizzy, Tower, Expert, Toob |
| +0x4380 | AthenaList | Expert: Spinner bridge list 1 | Expert |
| +0x4388 | float | Master: 0x42340000 (40.0f) | Master |
| +0x438C | Vec3List[8] | Toob: vector array (0x418 × 8) | Toob |
| +0x4390 | MeshNode* | Tower: Chomper visual mesh | Tower |
| +0x4394 | MeshWorld* | Tipper mesh | Dizzy, Master |
| +0x4398 | CollisionLevel* | Tipper visual/collision | Dizzy, Master |
| +0x439C | Vec3List[4] | Master: vector array (0x418 × 4) | Master |
| +0x43A0-A8 | float[3] | Tower: zero-init (0, 0, 0) | Tower |
| +0x43B4 | MeshWorld* | Tower: Turret mesh | Tower |
| +0x43B8 | AthenaList | Tower: object list | Tower |
| +0x4790 | AthenaList | Dizzy: secondary list | Dizzy |
| +0x4798 | AthenaList | Expert: Spinner bridge list 2 | Expert |
| +0x47D0 | AthenaList | Tower: list 2 | Tower |
| +0x47E0 | MeshWorld* | Spinny/Gear/Lifter mesh (Impossible, Spinny, Lifter factories) | Impossible, Spinny, Lifter |
| +0x47E4 | AthenaList | Tower Arena: list | Tower Arena |
| +0x4784 | MeshWorld* | Up: Lifter mesh | Up |
| +0x4788 | MeshWorld* | Up: SpeedCylinder mesh | Up |
| +0x478C | MeshWorld* | Up: TimeButton mesh | Up |
| +0x4BA8 | MeshWorld* | Dizzy: WaterWheel mesh | Dizzy |
| +0x4BAC | CollisionLevel* | Dizzy: WaterWheel collision | Dizzy |
| +0x4BC4 | MeshWorld* | Dizzy: Swirl mesh | Dizzy |
| +0x4BC8 | CollisionLevel* | Dizzy: Swirl collision | Dizzy |
| +0x4BE8 | AthenaList | Tower: list 3 | Tower |
| +0x4BFC | MeshWorld* | Tower Arena: Catapult mesh | Tower Arena |
| +0x4FD4 | void* | Expert: BELL object pointer | Expert |
| +0x5000 | AthenaList | Tower: list 4 | Tower |
| +0x5018 | MeshWorld* | Tower Arena: Turret mesh | Tower Arena |
| +0x540C | void* | Bonk object pointer (Level3/Master) | Dizzy, Master |
| +0x5410 | MeshWorld* | BreakBridge1 mesh | Master |
| +0x5414 | MeshWorld* | BreakBridge2 mesh | Master |
| +0x5418 | void* | BreakBridge1 object pointer | Master |
| +0x541C | void* | BreakBridge2 object pointer | Master |
| +0x5420 | MeshWorld* | PopCylinder mesh | Master |
| +0x5424 | MeshWorld* | PopCylinder2 mesh (Master only) | Master |
| +0x5428 | AthenaList | PopCylinder list | Master |
| +0x5840 | MeshWorld* | BlockDawg1 mesh | Master, Toob |
| +0x5844 | MeshWorld* | BlockDawg2 mesh | Master, Toob |
| +0x5848 | MeshWorld* | Catapult mesh | Master |
| +0x584C | AthenaList | Catapult list | Master |
| +0x5C64 | AthenaList | (unknown - Master) | Master |
| +0x607C | MeshWorld* | Gluebie mesh | Master |
| +0x6080 | AthenaList | Gluebie list | Master |
| +0x868 | char* | Board display name string | All |
| +0x870 | int | Race ID from App+0x14+0x1DC | All |
| +0x878 | App* | App pointer | All |
| +0x8AC | Scene* | Scene pointer (for Level_FindObjectByName) | All |
| +0x29B4 | char* | Race name string | All |
| +0x4344 | char* | Display/theme string | All |
11. Object Allocation Size Summary
| Object Type | Alloc Size | Constructor | Called From |
|---|---|---|---|
| Tipper | 0x1104 | Tipper_ctor | CreateLevelObjects |
| TipperVisual | 0x10D0 | TipperVisual_ctor | CreateLevelObjects |
| Bonk | 0x1200 | Bonk_ctor | CreateLevelObjects, CreateExpertLevelObjects |
| BreakBridge (BBRIDGE1/2) | 0x1100 | BreakBridge_ctor | CreateLevelObjects |
| PopCylinder | 0x10E8 | PopCylinder_ctor | CreateLevelObjects |
| BlockDawg | 0x1154 | Blockdawg_ctor | CreateLevelObjects |
| Catapult | 0x1108 | Catapult_ctor | CreateLevelObjects |
| Gluebie | 0x110C | Gluebie_ctor | CreateLevelObjects |
| Lifter | 0x10F4 | Rotator_ctor_sound | CreateUpLevelObjects, CreateLifter |
| SpeedCylinder | 0x150C | Pendulum_ctor | CreateUpLevelObjects |
| TimeButton | 0x10E8 | Rotator_ctor_nosound | CreateUpLevelObjects |
| Looper | 0x1500 | Looper_ctor | CreateMechanicalObjects |
| Gear | 0x1514 | Gear_ctor | CreateMechanicalObjects, Scene_CreateObject_Gear |
| BigGear | 0x1514 | Gear_ctor (same, with scale 0.5) | CreateMechanicalObjects |
| Rotator | 0x1508 | Rotator_ctor | CreateMechanicalObjects, CreateSpinny |
| Pendulum | 0x1504 | Pendulum_ctor | CreateMechanicalObjects |
| Spinny | 0x1508 | Rotator_ctor | CreateSpinny |
| Fan (TowerLevel) | 0x1188 | TowerLevel_Ctor | CreateExpertLevelObjects |
| Sawblade | 0x111C | Sawblade_Level_Ctor | CreateExpertLevelObjects |
| Spinner (Expert Bridge) | 0x10FC | Spinner_Level_ctor | CreateExpertLevelObjects |
| Judge | 0x1100 | Gear_Level_ctor | CreateExpertLevelObjects |
| Bell | 0x10E8 | Tipper_Level_Ctor | CreateExpertLevelObjects |
| MeshWorld | 0x10D0 | MeshWorld_ctor | All Board ctors |
| MeshNode | 0x18 | MeshNode_ctor | Tower, Expert Board ctors |
| CollisionLevel | 0x10D0 | CollisionLevel_ctorWithLevel | All Board ctors |
12. Difficulty Gate Summary
Objects that ONLY spawn on Hard difficulty (App+0x23C != 0):
- TIPPER (CreateLevelObjects)
- BONK (CreateLevelObjects, CreateExpertLevelObjects)
- BLOCKDAWG1 (CreateLevelObjects)
- BLOCKDAWG2 (CreateLevelObjects)
- GLUEBIE (CreateLevelObjects — inverted: skipped when App+0x23C == 0)
- FAN (CreateExpertLevelObjects)
- SAWBLADE (CreateExpertLevelObjects)
Objects that spawn regardless of difficulty:
- BRIDGE, BBRIDGE1, BBRIDGE2, POPCYLINDER, CATAPULT (CreateLevelObjects)
- LIFTER, SPEEDCYLINDER, TIMEBUTTON (CreateUpLevelObjects)
- LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM (CreateMechanicalObjects)
- SPINNY (CreateSpinny)
- GEAR (Scene_CreateObject_Gear)
- BRIDGE(Expert), JUDGE, BELL (CreateExpertLevelObjects)
13. param_block Layout (Confirmed)
The param_4 pointer points to a structure with:
+0x00: (header/flags)
+0x04: float position.X
+0x08: float position.Y
+0x0C: float position.Z
+0x10: float rotation.X (or secondary position for GEAR/BIGGEAR)
+0x14: float rotation.Y (or extra float for FAN/Spinner)
+0x18: float rotation.Z
For most objects, position is copied from param_4+4/+8/+0xC.
For GEAR/BIGGEAR, both position (param_4+4/+8/+0xC) AND rotation (param_4+0x10/+0x14/+0x18) are used.
For FAN and Expert BRIDGE (Spinner), param_4+0x14 is passed as an additional float parameter.
🔗 Related Documents
File Formats
Hamsterball File Formats
MESHWORLD Format (.MESHWORLD)
Binary level format containing object definitions with positions, rotations, and properties.
Header
- Bytes 0-3: Object count (uint32 LE)
- Bytes 4-7: First object type string length (uint32 LE)
Object Types (confirmed from Arena-Beginner.MESHWORLD)
START1-1,START2-1,START2-2- Player start positions (1P and 2P)FLAG02,FLAG04,FLAG06,FLAG07- Checkpoint flagsSAFESPOT- Safe landing spotCAMERALOOKAT- Camera look-at target (from earlier strings analysis)PLATFORM- Platform object
Arena-SpawnPlatform.MESHWORLD Analysis
First 0x22 bytes: Header with object count = 0x22 (34)
Then each object contains:
- 4 bytes: string length
- N bytes: null-terminated type string (e.g., "START2-2\0")
- Floats: position (x, y, z), orientation, scale data
- Per-object variable-length data
Key floats observed:
0x3F800000= 1.0f (scale/identity matrix values)0xC2480000= -50.0f0x41555555≈ 13.33f (y-position values)0x3E2B01F6≈ 0.167f (small scale factors)0x3F550000≈ 0.832f0xC2000000= -32.0f
Special object type: N:SINKPLATFORM - sinking platforms
E:NODIZZY<TIME>50</TIME> - modifier with XML-like embedded properties
Sub-objects within MESHWORLD
Each MESHWORLD contains:
- Vertex data (arrays of floats for positions, normals, UVs)
- Object definitions with type strings
- Embedded XML-like properties (e.g.,
<TIME>50</TIME>) - Texture references (e.g., "PinkChecker.bmp", "BlackCheckerFlag.png")
- Face/index data near end of file
MESH Format (.MESH)
Custom binary 3D mesh format used for game models.
Header Structure
Offset Size Description
0x00 4 Version (uint32 LE) = 1
0x04 4 Mesh name length (uint32 LE)
0x08 N Mesh name (null-padded ASCII)
Post-Header Data (Sphere.MESH)
After name string:
- Position: 3 floats (x, y, z) = (0, 0, 0)
- Scale: 3 floats (1.0, 1.0, 1.0)
- Unknown: 3 floats (1.0, 1.0, 1.0) - possibly bounding box extents
- Material properties: diffuse color RGB (3 floats), ambient (3 floats), specular (3 floats)
- Specular power: 1 float (25.0)
- More transform/material data
Then texture references:
4 bytes: texture name length (uint32 LE)
N bytes: texture filename (null-terminated, e.g., "HamsterBall.png")
After textures: vertex data arrays.
Known Meshes
Sphere.MESH- Default hamster ballSphere+Tar.MESH- Ball with tar textureSphereBreak1.MESH,SphereBreak2.MESH- Broken ball fragmentsHamster.MESH,Hamster-Trot1/2/3.MESH,Hamster-Waiting.MESH- Hamster model + animationsSawblade.MESH,SawFace.MESH- Sawblade obstacle- Various obstacle models (Fanblades, Chomper, etc.)
HS.CFG Format (Save/Config)
Binary format, 1300 bytes:
- Fixed-size records with null-padded strings
- Player names: "R. FINK", "SQUEAKS", "HAMMSTURABBI", "PEEPUMS", "MR. RAPTIS"
- Appears to store high scores per race category
- Each record: 64-byte name field + score data
RaceData.xml Format
XML structure per race:
<RACENAME>
<TIME>seconds</TIME>
<PAR>seconds</PAR>
<WEASEL>time</WEASEL>
<GOLD>time</GOLD>
<SILVER>time</SILVER>
<BRONZE>time</BRONZE>
<CAM>float</CAM> <!-- camera parameter -->
</RACENAME>
Race Categories (in order)
- BEGINNERRACE (60s, par 47s)
- CASCADERACE (50s, par 25s)
- INTERMEDIATERACE (45s, par 35s)
- DIZZYRACE (40s, par 35s)
- TOWERRACE (35s, par 35s)
- UPRACE (30s, par 25s)
- NEONRACE (30s, par 25s)
- EXPERTRACE (30s, par 20s)
- ODDRACE (30s, par 20s)
- TOOBRACE (25s, par 20s)
- WOBBLYRACE (25s, par 20s)
- GLASSRACE (25s, par 10s)
- SKYRACE (25s, par 5s)
- MASTERRACE (55s, par 2s)
- IMPOSSIBLERACE (50s, par 2s)
Jukebox.xml Format
Music configuration mapping race names to MO3 song indices:
<SONG> * comment *
<NAME>display name</NAME>
<HEX>hex_index</HEX>
</SONG>
The HEX value is a position/pattern index into the MO3 music file.
Font Description Format
Binary font metric file in Fonts/<name>/font.description:
- Entry count (uint32)
- Glyph count/character height
- Per-glyph entries: character code, x position (float), width (float), UV coordinates
- Paired with texture atlas PNGs (Data0.png, Data1.png, etc.)
MO3 Music Format
Music/Music.mo3 - MO3 format (BASS library tracker format)
- Contains all game music in a single module
- Indexed by HEX values from Jukebox.xml
- Uses BASS_MusicLoad/BASS_MusicPlayEx for playback
Sound Effects
All in Sounds/ directory as OGG Vorbis files.
Loaded via BASS library (BASS_ChannelSetAttributes, BASS_ChannelStop).
🔗 Related Documents
Font & Text Rendering System
Font & Text Rendering System
Overview
Hamsterball uses a custom bitmap font system with a binary descriptor format.
Fonts are loaded from font.description files with associated dataN.png glyph
atlases. The font renderer supports both 2D and 3D text rendering.
Font Loading (LoadFont 0x457130)
font.description Binary Format
Header:
uint32 page_count; // Number of glyph atlas pages
uint32 glyph_count; // Number of glyph definitions
Pages (page_count entries):
// Each page: "%s\\data%d.png" → Graphics_FindOrCreateTexture()
// Textures loaded as PNG files, alpha-enabled
Glyphs (glyph_count entries):
uint8 char_code; // ASCII character code (1 byte)
uint32 x_advance; // Horizontal advance pixels (4 bytes)
uint8[4] unknown1; // Unknown field (4 bytes)
uint8[4] unknown2; // Unknown field (4 bytes)
uint8[4] unknown3; // Unknown field (4 bytes)
float height; // Glyph height (4 bytes)
uint32 unknown4; // Unknown (4 bytes)
uint32 unknown5; // Unknown (4 bytes)
uint32 unknown6; // Unknown (4 bytes)
// Total: 33 bytes per glyph entry
FontList Structure (large, ~0x1490 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x00 | vtable* | FontList_ScalarDtor (0x4D8E30) |
| +0x04 | void* | Graphics device pointer (param_1) |
| +0x08 | AthenaList | Texture pages |
| +0x420 | int | Glyph count |
| +0x424 | int | Max glyph height (updated during load) |
| +0x428 | float | Scale (default 1.0f = 0x3F800000) |
| +0x42C | char[5120] | Per-character flags (0x500 * 4 = 0x1400) — glyph availability |
| +0x43C | void*[256] | Per-character sprite slots (256 * 0x14) |
| +0x434 | int[256] | Per-character x position offsets |
| +0x10E0 | int[256] | Per-character y position offsets |
| +0x430 | int[256] | Per-character widths |
Font Load Process
- Zero-initialize glyph table (0x500 * 4 bytes at +0x42C)
- Open
{font_dir}\\font.descriptionwith_open(path, 0x8000)(O_RDONLY) - Read page_count and glyph_count from header
- For each page: load
{font_dir}\\data{N}.pngvia Graphics_FindOrCreateTexture - For each glyph: read 33 bytes, call Gadget_AddSpriteSlot with glyph params
- Track max height at FontList+0x424
- Close file
Font Rendering
Font_DrawGlyph (0x457440)
Renders a string character by character:
for each char in text:
if char == '\n':
y_offset += line_height (+0x424)
x_position = start_x
elif glyph_available[char] (+0x42C offset):
if scale == 1.0:
Sprite_DrawRect(glyph_sprite, x + glyph_x_offset, y + glyph_y_offset)
else:
Scene_CreateObject4f(glyph_sprite, x, y,
scaled_width, scaled_height, ...)
advance x by glyph width
Font_DrawCentered (0x42C870)
8 xrefs — renders text centered at position:
- Measure text width with Font_MeasureText
- Center x = position - (width / 2)
- Call Font_DrawGlyph at centered position
Font_MeasureText (0x456E20)
56 xrefs — measures pixel width of text string:
- Sum per-character x_advance values
- Account for scale factor
- Return total width in pixels
Font_DrawGlyph3D (0x457690)
1 xref — renders text in world-space 3D coordinates.
Used for floating text in game world (goal markers, score displays).
Font_WordWrap (0x456E80)
3 xrefs — word-wraps text to fit within a maximum width.
Inserts newline characters where needed.
Font_RenderToTextureComplex (0x472340)
4 xrefs — renders text to an off-screen texture for compositing.
Used for complex text rendering (shadows, outlines, gradients).
Font_RenderChannels (0x4A91D0)
2 xrefs — renders font glyph channels (for font building).
Font Processing Pipeline (SDF Builder)
The game includes a full SDF (Signed Distance Field) font builder:
- Font_DecodeGlyphBits (0x4AD716) — Decode glyph bitmap from font description
- Font_IncrementGlyphMask (0x4B4CF2) — Advance glyph mask for coverage
- Font_ComputeGlyphBounds (0x4B4D5E) — Compute bounding box of glyph
- Font_SplitGlyphQuadrants (0x4B5096) — Quadtree split for SDF generation
- Font_ComputeGlyphCentroid (0x4B51E9) — Compute glyph center of mass
- Font_ComputeGlyphCoverage (0x4B5321) — Compute pixel coverage percentage
- Font_CompositeGlyph (0x4B60EE) — Composite glyph onto final texture
This pipeline generates the dataN.png glyph atlas files from TrueType fonts
during development. The runtime only loads the pre-built atlases.
Font Resources
Font Files (5 fonts loaded in App_ResourceLoader)
| App Offset | Path | Point Size | Usage |
|---|---|---|---|
| +0x318 | fonts\showcardgothic28 | 28pt | Title text, HUD |
| +0x31C | fonts\showcardgothic14 | 14pt | Small labels, scores |
| +0x320 | fonts\showcardgothic16 | 16pt | Medium labels |
| +0x324 | fonts\arialnarrow12bold | 12pt | UI body text |
| +0x328 | fonts\showcardgothic72 | 72pt | Large display text |
Each font directory contains:
font.description— Binary font descriptor (header + glyph defs)data0.png,data1.png, ... — Glyph atlas pages
Font Drawing API
| Address | Function | Description |
|---|---|---|
| 0x457130 | LoadFont | Load font from font.description file |
| 0x457440 | Font_DrawGlyph | Render text string (2D) |
| 0x457690 | Font_DrawGlyph3D | Render text in 3D world space |
| 0x42C870 | Font_DrawCentered | Center-aligned text rendering |
| 0x456E20 | Font_MeasureText | Measure pixel width of text |
| 0x456E80 | Font_WordWrap | Word-wrap text to max width |
| 0x472340 | Font_RenderToTextureComplex | Render to off-screen texture |
| 0x4A91D0 | Font_RenderChannels | Render glyph channels |
| 0x4AD716 | Font_DecodeGlyphBits | Decode glyph bitmap |
| 0x4B4CF2 | Font_IncrementGlyphMask | Advance glyph mask |
| 0x4B4D5E | Font_ComputeGlyphBounds | Compute glyph bounding box |
| 0x4B5096 | Font_SplitGlyphQuadrants | Quadtree split for SDF |
| 0x4B51E9 | Font_ComputeGlyphCentroid | Compute glyph center |
| 0x4B5321 | Font_ComputeGlyphCoverage | Compute pixel coverage |
| 0x4B60EE | Font_CompositeGlyph | Composite glyph onto atlas |
| 0x473940 | FontFormatString_Parse | Parse printf-style format strings |
| 0x475390 | Menu_AddFont | Add font to menu system |
| 0x472570 | MeshWorld_BuildFontMeshes | Build 3D text meshes from font data |
Format String Parsing (FontFormatString_Parse)
Parses printf-style format strings with %d, %s, %f, etc.
Uses CRT_ParseFormatString (0x4F7820) for format specifier extraction.
Supports format flags: digits, ., -, +, #, (standard printf).
Menu Text Positioning
Menu items use Matrix_Scale4x4 for text scaling:
- Each item has a 4-component scale: (X1, X2, Y1, Y2)
- (1.0, 1.0, 1.0, 1.0) = normal size
- (0.75, 1.0, 0.75, 1.0) = 75% width for emphasized items
- (1.0, 0.5, 0.5, 1.0) = half Y-size for "FRENZIED!" difficulty
🔗 Related Documents
FPS Mod
Hamsterball FPS Mod
A drop-in mod for the original Hamsterball Windows game that lets you set a custom FPS cap.
What it does
- Reads
TargetFPSandRenderFPSfromhamsterball_fps.ini - Writes those values into the game's live
Appstruct atApp+0x16CandApp+0x170 - Optionally enables the old "uncap" mode (
Uncap=1) which NOPs the render-skip throttle and forces immediate swap intervals
Installation (Windows)
- Copy these files into your Hamsterball game folder:
bass.dll(the proxy)hamsterball_fps.iniinstall_fps_mod.bat(optional helper)
- Double-click
install_fps_mod.bat, OR manually:- Rename the original
bass.dll→bass_real.dll - Rename
hamsterball_fps_mod.dll→bass.dll
- Rename the original
- Edit
hamsterball_fps.iniand set your desired FPS. - Launch the game normally.
Uninstallation
Run uninstall_fps_mod.bat, or manually delete the proxy bass.dll and rename bass_real.dll back to bass.dll.
Configuration
[FPS]
TargetFPS=144
RenderFPS=144
[Uncap]
Uncap=0
TargetFPS: update tick rate cap (default in original game: 88)RenderFPS: render frame cap (default in original game: 75)Uncap=1: additionally remove the render-skip check and vsync limits (may affect physics/timing)
Building from source (Linux + MinGW)
cd tools/bass_fps_proxy
make
make package # creates hamsterball-fps-mod.zip
How it works
The game loads bass.dll at startup. This proxy forwards every BASS call to the real bass_real.dll and, after the game has initialized, patches the live App singleton via the pointer at 0x005341E0.
Offsets used:
| Field | Offset | Original value |
|---|---|---|
| target_fps | 0x16C | 88 |
| render_fps | 0x170 | 75 |
Warning
Very high FPS can change physics behavior (ball friction, collision response, timer drift). Test carefully. The uncap mode in particular can cause the well-known "white screen" timing issue on some systems.
🔗 Related Documents
FPS Uncap
Hamsterball FPS Uncap — bass.dll Proxy
What This Does
Hamsterball has a built-in FPS limiter that caps rendering at ~100 FPS (in practice ~75 FPS on most systems due to 15.6ms timer granularity). This proxy DLL replaces bass.dll — the game ships it and imports 10 audio functions from it — with a shim that forwards all audio calls to the original (bass_real.dll) while patching the game's rendering limiter in memory.
Result: the game renders as fast as the GPU can push frames — uncapped FPS, butter-smooth motion.
Why bass.dll?
The game imports bass.dll for audio. Windows DLL search order loads our proxy first (same directory as the EXE), so our bass.dll loads instead of the original, then loads the original as bass_real.dll and forwards every function call to it. This is a classic "DLL proxy" technique — audio works identically, but our code runs inside the game's process with full memory access.
The Failed Approaches (and why they failed)
Before arriving at the final solution, we went through several iterations. Understanding why each failed is critical for understanding why the final approach works.
Attempt 1: Patch fps_target (100 → 1000)
Theory: The game has a struct field fps_target (at App+0x830) set to 100 in the constructor. Raising it to 1000 should raise the FPS cap.
Why it failed: fps_target is read by an idiv instruction in App_Run that computes render_interval = time_slice / fps_target. This idiv runs once at the top of App_Run, before the main loop begins. Our patches fire 500ms after DLL load (via a background thread) — by then, App_Run has already executed the idiv and stored render_interval in a stack variable. Patching the struct value after that point has zero effect on the running game.
Attempt 2: Patch the idiv instruction itself
Theory: Instead of patching the struct value, patch the idiv instruction in the code so it divides by 1000 instead of 100.
Why it failed: Same timing problem. The idiv executes once at function entry. Even if we patch the instruction bytes, the computation has already happened — the result is sitting on the stack as a local variable. Changing the code after it runs is useless.
Also: raising fps_target without first fixing the timer causes the frame_time threshold to go negative, producing a white screen. The game's GetTickCount() returns 15.6ms-resolution values (Windows default), so when fps_target is high enough that the expected frame time (1/FPS) is smaller than the timer resolution, the subtraction underflows. This is why you can't just raise fps_target — you must also hook GetTickCount with QueryPerformanceCounter first.
Attempt 3: Patch with short 3-byte patterns
Theory: Use byte-pattern matching to find and patch the cmp eax, 10 (spin counter cap) instruction.
Why it failed: The 3-byte pattern 83 F8 0A (cmp eax, 10) matches 13 locations in the .text section — most of which have nothing to do with the frame limiter. Overwriting all of them corrupted audio, D3D8 initialization, and rendering code paths. The game showed a white screen with no sound.
Fix: Restricted to a 9-byte context pattern (inc eax; cmp eax, 10; mov [esp+0x10], eax; jge) that uniquely identifies the spin-counter location. This fixed the corruption but didn't fix the white screen (because the root cause was the idiv timing issue, not the spin counter).
Attempt 4: The jbe NOP (WORKS)
The breakthrough: Stop trying to patch computed values that are baked into stack variables at init time. Instead, patch the render decision — a jbe (jump-if-below-or-equal) instruction that is checked every single frame.
At address 0x46BF55 in the game loop, there's a jbe skip_render instruction (76 5D). If the elapsed time since the last render is below the threshold, it jumps over the render path — skipping Present(). NOPing this jump (90 90) means the game always falls through to the render path, every single loop iteration, regardless of any timing value.
This works regardless of when our patch fires, because the instruction is re-evaluated every frame. It doesn't matter that render_interval was computed once at init — the jbe that gates rendering is checked continuously.
The Final Solution (v7)
Patches Applied (4 total)
| # | Patch | Address | Original Bytes | Patched Bytes | Purpose |
|---|---|---|---|---|---|
| 1 | GetTickCount IAT hook | kernel32.dll IAT entry | (pointer) | hooked_GetTickCount |
High-resolution timing — replaces 15.6ms GetTickCount with QPC-based sub-ms precision |
| 2 | Render-skip jbe NOP | VA 0x46BF55 |
76 5D |
90 90 |
Forces rendering every frame — the core FPS uncap |
| 3 | vsync disable (×2) | Pattern match | C7 86 F8 01 00 00 01 00 00 00 |
C7 86 F8 01 00 00 00 00 00 80 |
Sets D3DPRESENT_INTERVAL_ONE → D3DPRESENT_INTERVAL_IMMEDIATE (disables vsync, 2 locations) |
| 4 | timeBeginPeriod(1) | Win32 API call | N/A | N/A | Requests 1ms timer resolution from Windows |
Why each patch is needed
-
GetTickCount → QPC hook: Without this,
timeBeginPeriod(1)alone is unreliable on Windows 10 v2004+ / Windows 11. The game'sGetTickCount()calls return 15.6ms-resolution values, and the frame-time threshold calculation can underflow (go negative) when FPS targets are high. The QPC hook gives us sub-millisecond precision (100ns on most systems). -
jbe NOP (the core patch): This is the one that actually uncaps FPS. The game loop at
0x46BF55checks whether enough time has passed since the last render. If not, it jumps over thePresent()call. NOPing this jump forces a render every iteration. Because this instruction is checked every frame, it doesn't matter that our patch fires 500ms after startup. -
vsync disable: The game calls
CreateDevicewithD3DPRESENT_PARAMETERS.PresentationInterval = D3DPRESENT_INTERVAL_ONE(vsync on). This caps FPS at the monitor refresh rate (60 Hz for most). Patching the constant from1to0x80000000(D3DPRESENT_INTERVAL_IMMEDIATE) disables vsync so the GPU can present as fast as it renders. There are 2 occurrences in the binary. -
timeBeginPeriod(1): Requests that Windows use 1ms timer resolution instead of the default 15.6ms. This improves the resolution of
Sleep(),WaitForSingleObject(), and other timing functions the game may use internally.
What is NOT patched (and why)
- fps_target (100): Left at the original value. It's only read once at init to compute
render_interval, and we bypass that entire code path with the jbe NOP. Patching it would risk the white-screen underflow bug if our QPC hook hasn't initialized yet. - fps_divisor (75): Same — it's an init-time computed value. The jbe NOP makes it irrelevant.
- Spin counter cap: The game has a counter that force-renders after 10 skipped frames (a failsafe to prevent total freeze). Since we never skip renders, this counter never increments, so patching it is unnecessary.
File Layout
tools/bass_fps_proxy/
├── bass_fps_proxy.c — Full proxy DLL source (311 lines)
├── bass_exports.def — Module definition file (exports the 10 BASS functions)
├── bass.dll — Compiled proxy (statically linked)
└── README.md — This file
How to Build
Prerequisites
- MinGW cross-compiler:
i686-w64-mingw32-gcc(32-bit, since Hamsterball.exe is PE32 i386) - On Ubuntu/Debian:
apt install gcc-mingw-w64-i686
Build Command
i686-w64-mingw32-gcc -shared -o bass.dll bass_fps_proxy.c \
bass_exports.def -lwinmm \
-Wl,--enable-stdcall-fixup -O2 \
-static -static-libgcc -Wl,--add-stdcall-alias
Critical flags:
-static -static-libgcc: Statically links libgcc, otherwise the DLL depends onlibgcc_s_dw2-1.dllwhich won't exist on the target machine. Must include both flags.-Wl,--enable-stdcall-fixupand-Wl,--add-stdcall-alias: Ensures the exported function names match what the game imports (some imports use decorated names like_BASS_Init@20, some use plain names).-lwinmm: LinkstimeBeginPeriod/timeEndPeriod.bass_exports.def: Defines the 10 exports with their ordinals.
Verify No External Dependencies
i686-w64-mingw32-objdump -p bass.dll | grep "DLL Name"
Should show only kernel32.dll, user32.dll, winmm.dll, and bass_real.dll — NOT libgcc_s_dw2-1.dll.
How to Install
- In your Hamsterball game folder, rename the original
bass.dlltobass_real.dll - Copy the compiled
bass.dllinto the same folder - Launch the game
The proxy will:
- Load
bass_real.dlland forward all 10 BASS audio functions to it (audio works normally) - After 500ms, apply the 4 patches in a background thread
- Write a log file named
Hamsterball_fps.login the same folder
How to Remove
Delete the proxy bass.dll, rename bass_real.dll back to bass.dll.
Log Output
The DLL writes Hamsterball_fps.log next to the EXE:
Hamsterball FPS Uncap Proxy v7.0
=================================
Patches applied: 4
GetTickCount IAT hook: OK (QPC-based sub-ms timing)
render-skip jbe NOP @0x46BF55: OK (always render)
vsync: INTERVAL_ONE -> INTERVAL_IMMEDIATE
timer: timeBeginPeriod(1)
fps_target: 100 (ORIGINAL - correct physics)
QPC frequency: 10000000 Hz (100.000 ns resolution)
If the jbe patch fails (bytes don't match), it will show FAILED (bytes mismatch) — this means the game EXE is a different version than expected.
Technical Details
The jbe Instruction at 0x46BF55
In the game loop (App_Run / GameLoop at VA 0x46BD80), after computing physics and input, the game checks whether it's time to render:
; ... time calculation ...
cmp eax, [render_interval] ; has enough time elapsed?
jbe skip_render ; 0x76 0x5D — if not, skip Present()
; ... D3D Present() ...
skip_render:
; ... continue loop ...
The jbe (jump if below or equal) at 0x46BF55 is the gatekeeper. NOPing it (0x90 0x90) makes the CPU unconditionally fall through to the render path.
The vsync Pattern
Original: C7 86 F8 01 00 00 01 00 00 00
mov dword ptr [esi+0x1F8], 0x00000001 ; INTERVAL_ONE
Patched: C7 86 F8 01 00 00 00 00 00 80
mov dword ptr [esi+0x1F8], 0x80000000 ; INTERVAL_IMMEDIATE
This patches the PresentationInterval field of D3DPRESENT_PARAMETERS before CreateDevice is called. Two occurrences exist (likely one for each of the game's two CreateDevice paths — windowed and fullscreen).
The GetTickCount IAT Hook
We walk the PE import table, find the kernel32.dll import for GetTickCount, and replace the function pointer with our own hooked_GetTickCount(). Our version uses QueryPerformanceCounter to get sub-millisecond precision:
static DWORD WINAPI hooked_GetTickCount(void)
{
LARGE_INTEGER now;
QueryPerformanceCounter(&now);
return (DWORD)((now.QuadPart * 1000ULL) / g_qpc_freq.QuadPart);
}
This is necessary because timeBeginPeriod(1) alone is insufficient on Windows 10 v2004+ / Windows 11 — the OS no longer reliably honors the 1ms request for GetTickCount.
Key Addresses
All addresses assume image base 0x400000 (Hamsterball.exe default, no ASLR):
| Address | Description |
|---|---|
0x46BD80 |
Game loop (GameLoop / App_Run body) |
0x46BF55 |
jbe skip_render — the frame render gate (patched to NOP) |
0x4278E0 |
WinMain |
0x4FD680 |
App global instance |
0x4BA57B |
Game's operator_new |
Version History
| Version | Approach | Result |
|---|---|---|
| v1-v2 | Patch fps_target + fps_divisor | White screen (timer underflow) |
| v3-v4 | Add GetTickCount QPC hook + timeBeginPeriod | Audio works, FPS still capped at ~75 |
| v5-v6 | Raise fps_target to 1000 + fix timer first | Audio works, ~111 FPS ceiling (vsync still on) |
| v7 (early) | Add vsync disable + short 3-byte patterns | White screen (pattern overmatch corruption) |
| v7 (mid) | Fix patterns to 9-byte context | White screen (idiv timing issue — patches fire too late) |
| v8 | Remove idiv/fps_target patches, keep only jbe NOP + vsync + timer | Works — uncapped FPS |
| v7.0 (final) | Clean rewrite with absolute addressing + jbe NOP | Works — uncapped FPS, clean code |
🔗 Related Documents
FPS Unlock
Hamsterball FPS Unlock
Removes the game's built-in frame rate caps (100 Hz update / 75 Hz render).
What It Does
Hamsterball has two FPS caps:
- Update cap: 100 Hz (
App+0x16C= 100, frame_time = 1000/100 = 10ms) - Render cap: 75 Hz (
App+0x170= 75, render_time = 1000/75 = 13ms)
The game loop in App_Run (0x46BD80) checks if enough time has passed since the last update/render. If not, it skips the frame and calls Sleep(0). There's also a conditional jump (JBE at 0x46BF55) that skips the Present call if the render rate isn't met.
This DLL:
- Sets
App+0x16C= 1000 (update cap → 1000 Hz) - Sets
App+0x170= 1000 (render cap → 1000 Hz) - NOPs out the render-skip
JBEat 0x46BF55 (always render)
Files
| File | Description |
|---|---|
fps_unlock.dll |
The unlock DLL — inject into Hamsterball.exe |
fps_unlock.log |
Created on first run — confirms patches applied |
Usage
Method 1: DLL Injection (recommended)
Use any DLL injector (including the one in tools/<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>/):
- Copy
fps_unlock.dllto your Hamsterball game folder - Launch
Hamsterball.exe - Inject
fps_unlock.dllusing your preferred injector - Check
fps_unlock.logto confirm patches applied
Method 2: D3D8 Proxy (auto-load)
Rename fps_unlock.dll to d3d8.dll and place it next to Hamsterball.exe. The game will auto-load it on startup. The DLL forwards all D3D8 calls to the real d3d8.dll while applying the FPS patch.
(Note: proxy mode requires additional forwarding code — see the d3d8_proxy_logger tool for an example of how to build a full proxy DLL.)
What Changed (Technical Details)
App_Ctor (0x46DC40) — Default Values
param_1[0x5b] = 100; // App+0x16C = target FPS (100 Hz)
param_1[0x5c] = 0x4b; // App+0x170 = render FPS (75 Hz)
App_Run (0x46BD80) — Frame Timing
// Update cap: 1000 / App+0x16C = 10ms per update tick
frame_time = 1000 / app->target_fps; // 0x46BDF5: IDIV [ESI+0x16C]
// Render cap: 1000 / App+0x170 = 13ms per render
render_time = 1000 / app->render_fps; // 0x46BDAC: IDIV [ESI+0x170]
// Render-skip check at 0x46BF55:
// JBE 0x46BFB4 → skip Present if not enough time elapsed
// Patched to: NOP NOP (always render)
Patches Applied
| Address | Original | Patched | Description |
|---|---|---|---|
App+0x16C |
100 (0x64) |
1000 (0x3E8) |
Update FPS cap |
App+0x170 |
75 (0x4B) |
1000 (0x3E8) |
Render FPS cap |
0x46BF55 |
76 5D (JBE) |
90 90 (NOP) |
Render-skip conditional |
Limitations
- Vsync: The game uses D3D8 Present with default presentation interval. If your GPU driver forces vsync, the actual FPS will be limited to your monitor refresh rate. Disable vsync in your GPU control panel for true uncapped FPS.
- Physics: The game's physics simulation runs at the update rate. At very high FPS, physics may behave differently (faster/smoother). The game was designed for 100 Hz max.
- Menu: The FPS unlock affects both gameplay and menus.
Building
make
Cross-compiled with MinGW i686 (32-bit Windows target).
🔗 Related Documents
fps_mod_custom
fps_mod_custom
Custom FPS mod (writes App+0x16C/0x170)
Files
hamsterball_fps_mod.c— C source codehamsterball_fps_mod.dll— Compiled DLL (PE32 i386)hamsterball-fps-mod.zip— Packaged zip
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dll(or renamed DLL) into the game folder - Launch Hamsterball
🔗 Related Documents
fps_unlock_standalone
fps_unlock_standalone
Standalone fps unlock DLL (injector, not bass proxy)
Files
fps_unlock.c— C source codefps_unlock.dll— Compiled DLL (PE32 i386)
Proxy Type
Standalone DLL (not a bass proxy). Use an DLL injector or launcher tool.
🔗 Related Documents
fps_unlock_v9
fps_unlock_v9
Uncaps FPS (v9, QPC timer + vsync disable)
Files
bass_fps_proxy.c— C source codebass.dll— Compiled DLL (PE32 i386)hamsterball-fps-uncap-bass-v9.zip— Packaged zip
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dll(or renamed DLL) into the game folder - Launch Hamsterball
🔗 Related Documents
Fun Ball Mod
Fun Ball Mod
Info
- File:
bass.dll(proxy) - Effect: Spawns player 1's ball as the FunBall mesh every race
- Android-safe: No IAT hooks, no code caves, no D3D API calls
What it does
The game loads the FunBall mesh (index 10) into the ball mesh array at
board+0x244 but never assigns it — Ball_ctor2 always sets ball+0x754=0
(Sphere). This mod writes ball+0x754=10 every frame (20fps) so the game
renders the FunBall mesh for player 1's ball at the start of every race.
No button activation needed — it's automatic.
Build
i686-w64-mingw32-gcc -shared -o bass.dll fun_ball.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Installation
- Rename original
bass.dlltobass_real.dll - Copy mod
bass.dllto game folder - On Android/Wine: set DLL override to
nativeforbass.dll
🔗 Related Documents
Function Map
Hamsterball - Function Map
Binary: Hamsterball.exe (MD5: 7d25019366b8d7f55906325bd630d7fe)
Total functions: 3,781 (Ghidra analysis)
Documented: 3781/3781 (100%)
User-labeled: 975+
Sessions: 14-28 (50→71.6%), 29-39 (71.6→96%), 40-45 (96→97.6%), 46 (97.6→100%)
Entry Point and Lifecycle
| Address | Name | Description |
|---|---|---|
| 0x004BB4C8 | entry | CRT entry point (GetVersionEx, heap init, etc.) |
| 0x004278E0 | WinMain | Game entry: Init → Run → Shutdown |
| 0x00429530 | App_Initialize_Full | 26-step init sequence |
| 0x0046BB40 | App_Initialize | 12-step base init (vtable calls + D3D8) |
| 0x0046BD80 | App_Run | Game loop (PeekMessage → Update → Render) |
| 0x0046BA10 | App_Shutdown | Cleanup on exit |
Graphics Subsystem (Direct3D 8)
| Address | Name | Description |
|---|---|---|
| 0x00455380 | Graphics_Initialize | D3D8 init, adapter check, device creation |
| 0x00455A60 | Graphics_Defaults | Set default render states |
| 0x00453B50 | Graphics_BeginFrame | Begin frame/render setup |
| 0x00455A90 | Graphics_PresentOrEnd | Present frame or end scene |
| 0x004542C0 | Graphics_ctor | Graphics constructor (vtable 0x4D88A0, init render context, texture cache, frustum) |
| 0x00455360 | Graphics_dtor | Graphics destructor (cleanup + optional free) |
| 0x00454550 | Graphics_Cleanup | Release D3D objects, free texture path, clear cache |
| 0x00454000 | Graphics_SetTexturePath | Set custom texture prefix path (strdup at +0x7D8) |
| 0x00454060 | D3DFMT_ToString | Convert D3DFORMAT enum to debug string |
| 0x00454B50 | Graphics_SetViewport | Set viewport dimensions |
| 0x00454D30 | Graphics_Reset | Reset device with new params, CreateDevice twice |
| 0x00455D60 | Graphics_DrawScreenRect | Draw 2D screen rectangle (x,y,w,h → TLVERTEX triangle strip). 63 xrefs. |
| 0x00455110 | Graphics_ApplyMaterialAndDraw | Apply material/render states + draw textured geometry. 17 xrefs. |
| 0x00454190 | Graphics_SetRenderMode | Set shading mode, reset vertex shader, re-apply render states |
| 0x00455B80 | Graphics_SetStreamBuffers | Set vertex buffer stream sources for rendering |
| 0x00457FA0 | RenderContext_Init | Initialize render context struct (0x50 bytes, vtable 0x4D8E68) |
| 0x00401160 | Graphics_SetViewportClip | Set viewport clipping bounds from 4x4 matrix |
Audio Subsystem (BASS)
| Address | Name | Description |
|---|---|---|
| 0x0046A020 | LoadMusicFile | BASS_MusicLoad wrapper |
| 0x0046A4D0 | LoadJukebox | Parse jukebox.xml |
| 0x00442CE0 | OptionsMenu_ctor | Options screen (Resolution, Fullscreen, Color, Safe Mode, Volume, Key Remap, Mouse, Pause) |
| 0x00487E40 | BASS_SetConfig | IAT thunk |
| 0x00487E46 | BASS_Start | IAT thunk |
| 0x00487E4C | BASS_Init | IAT thunk |
| 0x00487E52 | BASS_Free | IAT thunk |
| 0x00487E58 | BASS_Stop | IAT thunk |
| 0x004794B0 | BASS_ErrorGetCode | IAT thunk |
| 0x004794B6 | BASS_MusicLoad | IAT thunk |
| 0x004794BC | BASS_ChannelStop | IAT thunk |
| 0x004794C2 | BASS_ChannelSetAttributes | IAT thunk |
| 0x004794C8 | BASS_MusicPlayEx | IAT thunk |
Level/Game World System
| Address | Name | Description |
|---|---|---|
| 0x0045DE30 | LoadMeshWorld | Load .meshworld level file |
| 0x004706E0 | MeshWorld_ctor | MeshWorld object constructor (0x488 bytes) |
| 0x00470930 | MeshWorld_Parse | Parse MESHWORLD text format (*MATERIAL, *MESH, etc.) |
| 0x00458970 | CreateMeshBuffer | Allocate mesh vertex/index buffer |
| 0x00458A80 | InitMaterialArray | Initialize material array |
| 0x0040D1C0 | GetLevelPath | Get path for level (levels/levelN) |
| 0x0040A120 | LoadRaceData | Parse racedata.xml |
Game Object Factories
| Address | Name | Description |
|---|---|---|
| 0x0040FA20 | CreateBumper | Create BUMPER1/2/3/4 objects |
| 0x00413CE0 | CreateBumper2 | Create bumper variant |
| 0x00410D00 | NeonCollisionEvents | Create E:LIMIT boundary |
| 0x004117B0 | CreateUpLevelObjects | Create SPEEDCYLINDER |
| 0x00412850 | HandleArenaCollisionEvents | Create N:SPINNER |
| 0x0040E250 | CreateExpertLevelObjects | Create SAWBLADE |
| 0x0040BF50 | CreateMouseTrap | Create MOUSETRAP |
| 0x0040C5D0 | DispatchCollisionEvents | Create E:NODIZZY |
| 0x00438B30 | CreateBonkPopup | Create BONKPOPUP feedback |
| 0x0041D060 | LevelBoard_Dizzy_ctor | BoardLevel3 constructor |
| 0x0040BAA0 | CreateSecretObjects | Create SECRET and SECRETUNLOCK objects |
| 0x0043DFB0 | Secret_ctor | Secret object constructor (0x10EC bytes) |
| 0x004121D0 | CreateLevelObjects | Multi-factory: BRIDGE, TIPPER, BONK, BBRIDGE1/2, POPCYLINDER, BLOCKDAWG1/2, CATAPULT, GLUEBIE |
| 0x004133E0 | CreatePlatformOrStands | Create PLATFORM and STANDS objects |
| 0x00417FE0 | CreateMechanicalObjects | Multi-factory: LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM |
| 0x00437040 | Platform_ctor | Platform object constructor (0x10FC bytes) |
| 0x00462850 | Stands_ctor | Stands/Audience constructor (0x10D0 bytes) |
| 0x00435800 | Looper_ctor | Looper (loop-de-loop) constructor (0x1500 bytes) |
| 0x00437590 | Gear_ctor | Gear/BigGear constructor (0x1514 bytes) |
| 0x00435940 | Rotator_ctor | Rotator constructor (0x1508 bytes) |
| 0x00437700 | Pendulum_ctor | Pendulum constructor (0x1504 bytes) |
| 0x00437960 | Tipper_ctor | Tipper (tipping platform) constructor (0x1104 bytes) |
| 0x004661A0 | TipperVisual_ctor | Tipper visual component |
| 0x00465200 | TipperVisual_Attach | Attach visual to tipper |
| 0x00438850 | Bonk_ctor | Bonk (hammer) constructor (0x1200 bytes) |
| 0x00436D70 | BreakBridge_ctor | Breakable bridge constructor (0x1100 bytes) |
| 0x00436EE0 | PopCylinder_ctor | Pop cylinder (pop-up obstacle) constructor (0x10E8 bytes) |
| 0x0043C310 | Blockdawg_ctor | Blockdawg (block creature) constructor (0x1154 bytes) |
| 0x00437E10 | Catapult_ctor | Catapult constructor (0x1108 bytes) |
| 0x00437CB0 | Gluebie_ctor | Gluebie (glue blob) constructor (0x110C bytes) |
Camera System
| Address | Name | Description |
|---|---|---|
| 0x00413280 | CameraLookAt | Set camera look-at (CAMERALOOKAT) |
Menu/UI Screens
| Address | Name | Description |
|---|---|---|
| 0x0042DE50 | MainMenu_ctor | Main menu (LET'S PLAY, HIGH SCORES, OPTIONS, CREDITS, EXIT) |
| 0x00442CE0 | OptionsMenu_ctor | Options (Resolution, Fullscreen, Color, Volume, Remap, Mouse) |
| 0x004254E0 | CreditsScreen_ctor | Credits scrolling screen (formerly Physics_Init) |
| 0x0042B470 | HighScoreEntry_ctor | High score entry screen (name input + score display) |
| 0x0042BD40 | HighScoreEntry_Render | Render high score entry UI |
| 0x0042E060 | GameSelectionScreen_ctor | Tournament difficulty selector |
| 0x0044FD60 | SaveTourneyDialog_ctor | Save tournament dialog |
| 0x004476B0 | RegisterDialog_ctor | Register/purchase dialog |
| 0x0042E6F0 | QuitRaceMenu | Race quit confirmation menu |
Ball Physics System
| Address | Name | Description |
|---|---|---|
| 0x00403100 | Ball_SetTiltedGravity | Set gravity plane to tilted (value 1, normal -1,0,0) |
| 0x00403150 | Ball_SetFlatGravity | Set gravity plane to flat (value 2, normal 0,0,1) |
| 0x00403850 | Ball_SetTrajectory | Set ball trajectory direction + force scale |
| 0x00403750 | Ball_ApplyTrajectory | Apply trajectory force (normalize+scale direction, play sound, frame counter=100) |
| 0x00403980 | Ball_FindMeshCollision | Wrapper for Mesh_FindClosestCollision |
| 0x00401DD0 | Ball_CreateTrailParticles | Create trail particles (10 iterations, spawn 0x28 byte objects) |
| 0x00401920 | Ball_RenderShadow | Render ball shadow (scale by radius*constant, position at ball XYZ) |
| 0x00402BC0 | AthenaList_SetIndex | Set list iteration index |
Collision System
| Address | Name | Description |
|---|---|---|
| 0x00465EF0 | Collision_TraverseSpatialTree | Recursive octree traversal for collision tests |
| 0x00453780 | AthenaList_Append | Append item to dynamic AthenaList |
Level Rendering
| Address | Name | Description |
|---|---|---|
| 0x0040B090 | Level_InitScene | Init camera/scene (projection, fog, find CAMERALOCUS) |
| 0x0040B420 | Level_RenderDynamicObjects | Render moving objects (platforms) using Timer position |
| 0x0040B570 | Level_RenderObjects | Iterate objects calling vtable+0x0C (render callback) |
| 0x0040B600 | Level_UpdateAndRender | Full update pass: merge lists, pre-render, shadow, cleanup |
| 0x0040B9C0 | Level_SetObjectTransform | Set world transform from position data |
| 0x0040ACA0 | Level_SelectCameraProfile | Select camera profile by level difficulty (4-15) |
Level/Collision Loading
| Address | Name | Description |
|---|---|---|
| 0x004652E0 | CollisionLevel_ctor | Collision-only level (.meshcollision format) |
| 0x00465260 | Level_LoadCollision | Load binary collision mesh (planes, objects, AABB) |
| 0x004624C0 | Level_Cleanup | Level destructor (free objects, VBs, textures) |
Arena Initialization
| Address | Name | Description |
|---|---|---|
| 0x00413C20 | ArenaLevel_WarmUp_Init | Initialize Warm-Up arena (levels\arena-WarmUp) |
| 0x00416F40 | ArenaLevel_Neon_Init | Initialize Neon arena (levels\arena-neon) |
Game Logic
| Address | Name | Description |
|---|---|---|
| 0x00469CF0 | GameUpdate | Main update tick |
| 0x00428160 | PauseGame | Pausing (RightButtonPause) |
| 0x0042FAD0 | QuitRace | Quit current race |
| 0x0042E6F0 | QuitRaceMenu | Race quit menu |
| 0x004298C0 | TimerDisplay | Race timer display |
| 0x004254E0 | CreditsScreen_ctor | Credits scrolling screen (formerly mislabeled Physics_Init) |
| 0x0040ABA0 | CheckArenaUnlock | Check arena unlock conditions |
| 0x0040A420 | CheckPurchaseOrHighScore | Purchase reminder / high score check |
Config/Save System
| Address | Name | Description |
|---|---|---|
| 0x004279F0 | LoadOrSaveConfig | Config load/save dispatcher |
| 0x0042AE80 | LoadConfig | Load HS.CFG |
| 0x0042B6E0 | SaveConfig | Save HS.CFG |
| 0x00433AC0 | GameSelectionManager | Tournament save/load (TOURNAMENT.SAV) |
Font/Text Rendering
| Address | Name | Description |
|---|---|---|
| 0x00457130 | LoadFont | Load font.description + PNG glyphs |
| 0x00456E20 | Font_MeasureText | Measure text string width for centering |
| 0x00457440 | Font_DrawGlyph | Core glyph rendering (1 call = 1 glyph quad) |
| 0x004013A0 | UI_DrawTextCenteredAbsolute | Draw centered text (x - width/2) |
| 0x00409C60 | UI_DrawTextCentered | Draw centered text with shadow |
| 0x004012C0 | UI_DrawTextShadow | Draw text with shadow (offset + main) |
DRM System
| Address | Name | Description |
|---|---|---|
| 0x00429200 | ESellerate_Init | eSellerate DRM initialization |
Math Utilities
| Address | Name | Description |
|---|---|---|
| 0x00401AA0 | Vec3_NormalizeAndScale | Normalize 3D vector and scale to length param_1. 59 xrefs — most common math utility. |
| 0x00401D60 | Matrix_TransformVec3 | Transform 3D vector by 4x3 matrix. 15 xrefs. |
| 0x00453150 | Matrix_Scale4x4 | Set 4x4 matrix row scale values. |
| 0x00453200 | Matrix_Identity | Set matrix to identity (vtable pointer). |
| 0x004532E0 | AthenaList_SortedInsert | Insert with insertion-sort (ascending/descending). |
| 0x00458B50 | Matrix_ScaleTransform | Create 4x4 matrix by scaling source rows. |
| 0x0040A050 | Color_RandomRGBA | Generate 32-bit color from 4 random bytes. |
| 0x004580D0 | AABB_ContainsPoint | Test if point inside AABB (6 floats). |
| 0x00401010 | Vec3_Copy | Copy 4 floats (Vec3+padding) from source, preserves vtable |
| 0x00401040 | Vec3_Init | Init Vec3: set vtable 0x4CF300 + copy 4 floats from source |
| 0x00401070 | Vec3_dtor | Vec3 deleting destructor: reset to identity, optionally free |
| 0x004016c0 | Vec3_Scale | Multiply Vec3 by scalar: out = scalar * this |
| 0x00401890 | Vec3_DivideByScalar | In-place Vec3 division via reciprocal multiply with g_one (1.0f) |
| 0x004018c0 | Vec3_AddTwo | Add two Vec3s: out = this + rhs |
| 0x004018f0 | Vec3_AddInPlace | In-place Vec3 addition: this += rhs |
| 0x00401d20 | Vec3_Distance | Euclidean distance between two 3D points (6 float params) |
| 0x0040a020 | AthenaList_GetAt | Get element by index with bounds check; returns 0 if OOB |
Misc/Utility
| Address | Name | Description |
|---|---|---|
| 0x00429450 | FinishLoad | Finish loading (calls final setup) |
| 0x00457AD0 | Timer_Init | High-resolution timer init |
| 0x00457A40 | Timer_Cleanup | Timer cleanup |
Key Global Data
| Address | Name | Description |
|---|---|---|
| 0x004FD680 | g_App | Global App singleton object |
| 0x004D9CDC | MeshWorld_vtable | MeshWorld virtual function table |
App Object Layout (from App_Initialize_Full)
| Offset | Field | Description |
|---|---|---|
| 0x000 | vtable | Virtual function table pointer |
| 0x004 | hInstance | Windows HINSTANCE |
| 0x008 | cmdShow | nCmdShow parameter |
| 0x054 | registryKey | Registry/config key |
| 0x05C | targetFPS | Target frame rate |
| 0x15C | width | Window width |
| 0x158 | windowed | Windowed mode flag |
| 0x160 | height | Window height |
| 0x174 | graphics | Graphics object pointer |
| 0x17C | audioSystem | audio system pointer |
| 0x180 | d3dDevice | D3D device pointer |
| 0x200 | initialized | Init complete flag |
| 0x208 | initStep | Current init step string |
| 0x240 | cursor | Loaded cursor handle |
| 0x278 | shadowTexture | Shadow texture |
| 0x534 | musicHandle | BASS music handle |
| 0x538 | musicChannel2 | Second music channel |
| 0x53C | musicChannel1 | First music channel |
| 0x550 | gameMode1 | Game mode object (1) |
| 0x554 | gameMode2 | Game mode object (2) |
| 0x558 | gameMode3 | Game mode object (4) |
| 0x55C | gameMode4 | Game mode object (5) |
| 0x914 | playCount | Play count from registry |
| 0x159 | quitFlag | Game quit flag |
| 0x15A | someFlag | Another control flag |
| 0x156 | updateDisabled | Update disabled flag |
| 0x5A | frameTimeMs | Frame delta time |
| 0x5B | fpsDenominator | FPS denominator |
| 0x5D | renderTarget | Render target ptr |
| 0x65 | frameCounter | Per-second frame counter |
| 0x84 | profilingSection | Current profiling section name |
| 0x1B4 | versionString | Version string |
| 0x1CC | loadedCount | Loaded objects counter |
Texture System
| Address | Name | Description |
|---|---|---|
| 0x00471750 | LoadMesh | Load discrete .mesh files (uses same parser as MESHWORLD) |
Graphics Pipeline (27-step init)
| Address | Name | Description |
|---|---|---|
| 0x00455380 | Graphics_Initialize | Full 27-step D3D8 init |
| 0x00453C90 | Graphics_CreateDevice | D3D8 CreateDevice with format fallbacks (0x4b, 0x4f, 0x49, 0x47, 0x50, 0x4d) |
| 0x0042C810 | Graphics_InitRenderStates | Step 20: Initialize render states |
| 0x00454F10 | Graphics_SetViewport | Step 22: SetViewport(0,0,w,h) |
| 0x00454AB0 | Graphics_SetProjection | Step 23: SetProjection(10.0, 5000.0) |
| 0x00454630 | Graphics_SetupLights | Step 25: Setup D3D8 lights |
Collision Event System
| Address | Name | Description |
|---|---|---|
| 0x0040C5D0 | DispatchCollisionEvents | Main collision event dispatcher (E:NODIZZY, E:SAFESWITCH, E:LIMIT, E:BREAK, E:JUMP, E:ACTION, E:TRAJECTORY, N:NOCONTROL, N:WATER, N:TARPIT, N:GOAL, N:MOUSETRAP, N:SECRET, N:UNLOCKSECRET, DROPIN, PIPEBONK, POPOUT) |
| 0x0040E6A0 | ExpertCollisionEvents | Arena-specific collision handler (E:CALLHAMMER, E:HAMMERCHASE, E:ALERTSAW1/2, E:ACTIVATESAW1/2, E:ALERTJUDGES, E:SCORE, E:JUMP, E:BELL + delegates to DispatchCollisionEvents) |
| 0x0040DCD0 | TowerCollisionEvents | Level-specific collision handler (E:CATAPULTBOTTOM, E:OPENSESAME, N:TRAPDOOR, E:BITE, E:MACETRIGGER, N:MACE + delegates to DispatchCollisionEvents) |
| 0x00434770 | Saw_AlertActivate | Activate saw blade (alert mode - clear flag, play 3D sound) |
| 0x00434A50 | Saw_Activate | Activate saw blade (full - set active flag, play 3D sound) |
| 0x00434C40 | Judge_Reset | Reset judges (clear active flag, re-add to list) |
| 0x00434C80 | ScoreDisplay_SetTime | Set score display time with format string |
| 0x00434E20 | Bell_Activate | Activate bell (play sound, set animation) |
| 0x00438BB0 | Hammer_ChaseStart | Start hammer chase (set chasing flag, copy positions) |
| 0x00434290 | Catapult_Launch | Catapult launch (set active + timer) |
| 0x004344D0 | Trapdoor_Open | Open trapdoor (set scale from 0 to 1.0) |
| 0x00438410 | Trapdoor_Activate | Activate trapdoor (play 3D sound, set timer) |
Sound System (BASS)
| Address | Name | Description |
|---|---|---|
| 0x00459860 | Sound_Play3D | Play 3D positioned sound (set position via BASS, play channel) |
| 0x004597B0 | Sound_PlayChannel | Play sound channel (check if playing, dispatch from pool or allocate) |
| 0x004595B0 | Sound_StartSample | Start BASS sample (via vtable: reset, volume, 3D position) |
| 0x00466750 | Sound_CalculateDistanceAttenuation | 3D distance attenuation (find closest listener, apply min/max rolloff) |
Race/Timer System
| Address | Name | Description |
|---|---|---|
| 0x0044C260 | RaceResultPopup_ctor | Race end popup showing rank + TIME'S UP! / OUT OF TIME! text |
| 0x004298C0 | TimerDisplay | Race timer display |
Audio Pipeline
| Address | Name | Description |
|---|---|---|
| 0x00474540 | Audio_Init | BASS_Init (tries -1, then 0 device), BASS_Start, BASS_SetConfig |
| 0x0046A310 | Audio_PlayMusic | BASS_MusicPlayEx wrapper |
| 0x0046A020 | LoadMusicFile | BASS_MusicLoad wrapper (already labeled) |
Supported Display Modes (from Graphics_Initialize)
- 640x480 (0x280 x 0x1E0)
- 800x600 (0x320 x 0x258)
- 1024x768 (0x400 x 0x300)
- 1280x1024 (0x500 x 0x400)
Supported Refresh Rates
- 60Hz (0x14)
- 70Hz (0x15)
- 72Hz (0x16)
- 75Hz (0x17)
- 80Hz (0x18)
- 85Hz (0x19)
- 90Hz (0x1a)
|- 100Hz+ (0x17 fallback)|
Scene System (Core Lifecycle)
| Address | Name | Description |
|---|---|---|
| 0x00419770 | Scene_dtor | Master scene destructor. Destroys all AthenaList items (dynamic +0x335, sub-objects +0x43B, static +0x22E), frees level clones (+0x22B/+0x22C), resources (+0x21F, +0xE92), clears 8+ AthenaLists, destroys matrix arrays, cleanup |
| 0x00419C00 | Scene_Update | Main scene tick. Demo timer (buy dialog), unpause check, ball position updates from +0xA75 list, camera tracking from App+0x5DC, screen offset animation (+0xA6E, -10/frame to 0 or -800), update+delete static objects in +0x22E, vmethods +0x4C/0x50/0x54/0x58 (render passes), iterate dynamic objects +0xD8B |
| 0x0041A2E0 | Scene_Render | Main render dispatch by player count. Mode 0 (1P): render3D/renderObjects/renderOverlay then HUD. Mode 1 (2P): setup camera from player list +0x3A38, same. Mode 2 (3-4P split): per-viewport Graphics_SetViewport, camera per player |
| 0x00419FA0 | Scene_SetCamera | Camera positioning. Ball pos from +0x758 + scene offset +0x434C. Boundary check +0x3F1C: distance clamp with sqrt+falloff. Object+0x744 noise randomizer. Timer +0x3F2C: snap to ball for N frames. FOV 0.9 from +0x29BC |
| 0x0040DFA0 | Scene_RenderWithCamera | Two-pass camera render: front face then 180-degree back face. Camera angle +0x43A0, Y offset +0x43A4, X offset +0x43B0, iterator vtable +0x4390 |
| 0x00418870 | Scene_CreateObject4f | Create object at (x,y,z,w) with BaseObject vtable 0x4CF584, set position via FUN_45D450 |
| 0x0040C0F0 | Scene_CreateFlags | Scan for FLAG/SMALLFLAG objects, create Flag (0x8C size), append to +0x2160, scale SMALLFLAG by constant |
| 0x0040C270 | Scene_CreateSigns | Scan for SIGN objects, create Sign (0x10FC size), SIGN-TARPIT gets tar texture, added to +0xCD4 and render lists |
| 0x0040C430 | Scene_CreateDynamicObjects | Generic object creation loop via vmethod +0x84, append to +0x335 (dynamic) and level lists, also +0x43B sub-objects |
| 0x00419B70 | Scene_ForEachBall_SetVelocity | Iterate ball list +0x29D4, call Ball_SetVelocity on each |
SceneObject Class (vtable 0x4D934C, size 0xD4)
| Address | Name | Description |
|---|---|---|
| 0x0046B4F0 | SceneObject_ctor | Constructor. Sets vtable, initializes 3 matrix transforms (base scale +0x94, rotation +0xA8, world +0xBC), pos=0, visible +0x88=1, zOrder +0x8C=-1, radius +0xCC=sqrt(const), type +0xD0=3 |
| 0x0046B650 | SceneObject_dtor | Destructor: calls Cleanup then optionally frees |
| 0x0046B3F0 | SceneObject_Cleanup | Reset vtable, unlink from scene list (+0x710-0x730), reset 3 identity matrices |
| 0x0046B4D0 | SceneObject_SetVisible | Set visibility flag +0x88, call vmethod +0xC |
| 0x00453BD0 | Scene_RegisterObject | Assign ID to obj+0x8C, call vmethod +0xC (init), store in scene array +0x710+id*4 |
SceneObject Structure Layout
| Offset | Type | Field | Description |
|---|---|---|---|
| +0x000 | void* | vtable | Virtual function table pointer (0x4D934C) |
| +0x004 | int | gfxContext | Graphics context pointer |
| +0x008-0x01C | int[6] | field_08 | Zeroed at init |
| +0x088 | char | visible | Visibility flag (1=visible) |
| +0x08C | int | zOrder | Z-order / object ID (-1 default) |
| +0x094 | float[5] | baseScaleMatrix | Base scale 4x4 matrix (identity 1.0) |
| +0x0A8 | float[5] | rotationMatrix | Rotation 4x4 matrix (zero at init) |
| +0x0BC | float[5] | worldMatrix | World transform 4x4 matrix (zero at init) |
| +0x0CC | float | radius | Bounding radius = sqrt(global_constant) |
| +0x0D0 | int | type | Object type (3 default) |
Level Setup Functions
| Address | Name | Level Path | Special Features |
|---|---|---|---|
| 0x00416270 | Scene_SetupLevelDark | levels\leveldark | 2-player SceneObjects when !multiplayer, App+0x5DC/+0x67C |
| 0x0040E190 | Scene_SetupLevel5 | levels\level5 | Simple load (no extras) |
| 0x0040EA90 | Scene_SetupLevel6 | levels\level6 | LAUNCH01/02/03 + CHROMESHADOW positions, launcher timer +0x10DD=200 |
| 0x0040F360 | Scene_SetupLevel7 | levels\level7 | Simple load (no extras) |
| 0x00410830 | Scene_SetupLevel9 | levels\level9 | PILLAR list, MAGNIFYER on hard, CLOUDSCAPE, fog + projection setup |
| 0x00411F60 | Scene_SetupLevel10 | levels\level10 | 4 bumpers, FUN_436FC0 removal on easy, TarBubble list, multiplayer append |
| 0x004110D0 | Scene_SetupLevelCascade | levels\levelcascade | 8 bumpers (N:BUMPER%d 0-7) — Beginner Race (internal name: Cascade) |
| 0x00411540 | Scene_SetupLevelUp | levels\levelup | Initial ball pos (0,50,0), VAC-IN/VAC-OUT vacuum tubes |
Reflection Rendering Passes
| Address | Name | Object Count | Offset Base | Float Offset |
|---|---|---|---|---|
| 0x00410670 | Scene_RenderReflectiveObjects | 8 | +0x438C | +0x644C |
| 0x00411380 | Scene_RenderReflectiveObjects7 | 8 | +0x436C | +0x642C |
| 0x00412DC0 | Scene_RenderReflectiveObjects4 | 4 | +0x439C | +0x53FC |
Extended Utility Functions
| Address | Name | Description |
|---|---|---|
| 0x00453180 | Vec3_Init | Initialize Vec3 with vtable 0x4CF300, 3 floats, default scale 1.0 |
| 0x0040A0B0 | Matrix_TransformVec3 | Transform 3D vector by 4x4 matrix: result = M * v |
| 0x00426E90 | StdString_Assign | MSVC std::string::assign (SSO 0xF threshold, word+byte copy) |
| 0x0040A040 | NoOp | Empty function (58 xrefs, default/no-op handler) |
| 0x00409AC0 | BaseObject_Init | Set vtable pointer to 0x4CF584 (54 xrefs) |
| 0x00409D60 | RepeatCall | Call function pointer N times (34 xrefs) |
Ball Extended Functions
| Address | Name | Description |
|---|---|---|
| 0x0040AF90 | Ball_GetTransform | Read ball transform (+0x6C/+0x70/+0xC0/+0xC4) into output struct |
| 0x0040AF00 | SceneObject_InitAtPosition | Initialize SceneObject at (x,y) with Vec3 vtable |
UI and Input (Extended)
| Address | Name | Description |
|---|---|---|
| 0x00409B90 | UI_DrawTextShadow_Wrapper | Wraps UI_DrawTextShadow with Vec3 default params |
| 0x00428F10 | Input_CheckKeyCombo | Check key combos. param_1=2: escape. 0-3: iterate 4 input bindings, 50-frame debounce |
Game State Management (Extended)
| Address | Name | Description |
|---|---|---|
| 0x004287C0 | App_StartRace | Restart audio, setup race, play sound, free dialogs, start music (vol 1.0/0.5) |
| 0x00428ED0 | Difficulty_GetTimeModifier | Time modifier by difficulty: 0=easy, 1=normal, 2=hard, default=0.0 |
| 0x00413BD0 | SinkPlatformArenaCollisionEvents | Match "DN:SINKPLATFORM" name, call sinking behavior, then base handler |
Level Texture/Scale Assignment
| Address | Name | Description |
|---|---|---|
| 0x004130E0 | Level_AssignTextures | Match object textures by ID against 20-slot table at App+0x2C8 |
| 0x00411BA0 | Level_AssignTexturesAndScales | Set scale based on max(x,y,z) vs threshold, then match textures |
Scene Vtable Layout (0x4D0260, 36 entries)
| Slot | Offset | Address | Name | Description |
|---|---|---|---|---|
| 0 | +0x00 | 0x425020 | Scene_DeletingDtor | Destructor + free if flag&1 |
| 1 | +0x04 | 0x419C00 | Scene_Update | Main tick (9-step: input, update pipeline, ball update+respawn, race-end, countdown, objects, cameras, render, HUD) |
| 2 | +0x08 | 0x41A2E0 | Scene_Render | 1P/2P/split render dispatch |
| 3 | +0x0C | 0x4692F0 | Scene_HandleInput | Iterate menu items, check input, set current item at +0x864, play sound |
| 4 | +0x10 | 0x469220 | Scene_ActivateCurrentItem | Call vmethod+0x10 on current menu item (+0x864) |
| 5 | +0x14 | 0x4130A0 | Scene_vmethod5 | Unknown (arena-specific override) |
| 6 | +0x18 | 0x469280 | Scene_SelectCurrentItem | Call vmethod+0x0C on current menu item (+0x864) |
| 7 | +0x1C | 0x409D90 | Scene_NoOp | Empty (no-op stub, 3 bytes) |
| 8 | +0x20 | 0x40B400 | Level_RenderDynamicObjects | Level dynamic object rendering |
| 9 | +0x24 | 0x44B840 | NoOp_return | Empty stub (default) |
| 10 | +0x28 | 0x44B840 | NoOp_return | Empty stub (default) |
| 11 | +0x2C | 0x4692A0 | Scene_ClearCurrentItem | Set current item ptr (+0x864) to NULL |
| 12 | +0x30 | 0x4692A0 | Scene_ClearCurrentItem | Same as slot 11 |
| 13 | +0x34 | 0x44B840 | NoOp_return | Empty stub (default) |
| 14 | +0x38 | 0x409DA0 | Scene_DestroyScene | Calls FUN_4693C0 then SaveAndCleanup |
| 15 | +0x3C | 0x469430 | Scene_NotifyObjects | Iterate AthenaList, call FUN_4699D0 on each item |
| 16 | +0x40 | 0x419740 | Scene_SetDestroyed | Set +0x2C=1 destroyed flag |
| 17 | +0x44 | 0x4692B0 | Scene_SaveAndCleanup | Calls FUN_469AC0 (save + cleanup) |
| 18 | +0x48 | 0x40B090 | Level_InitScene | Level scene initialization |
| 19 | +0x4C | 0x41B130 | Scene_HandleRaceEnd | Race timer decrement, lap 3->4 transition, Game Over, RaceResultPopup |
| 20 | +0x50 | 0x41B540 | Scene_UpdateBallsAndState | Iterate ball lists, SetCamera, Ball_Update, destroy finished balls |
| 21 | +0x54 | 0x40A040 | NoOp | Empty (58 xrefs) |
| 22 | +0x58 | 0x41A540 | Scene_ProcessRaceEnd | Countdown timer, check all balls finished, scene transition on expire |
| 23 | +0x5C | 0x409DE0 | Scene_HandleBallFinish | Ball finish state machine: start→countdown(150f)→finish→result popup→done |
| 24 | +0x60 | 0x40B420 | Level_RenderDynamicObjects_2 | Alternate dynamic render |
| 25 | +0x64 | 0x40B600 | Level_UpdateAndRender | Combined update + render |
| 26 | +0x68 | 0x40B570 | Level_RenderObjects | Level object rendering |
| 27 | +0x6C | 0x41B710 | Scene_RenderScoreHUD | Draw tournament title, countdown progress bar, "Score" text, Player 2, timer |
| 28 | +0x70 | 0x41BFD0 | Scene_RenderTimerHUD | Draw race timer, split screen divider, time display, overlay popups |
| 29 | +0x74 | 0x40C5D0 | Scene_vmethod29 | Game logic override |
| 30 | +0x78 | 0x44B840 | NoOp_return | Empty stub (default) |
| 31 | +0x7C | 0x41AC70 | Scene_vmethod31 | Game logic override |
| 32 | +0x80 | 0x41C5B0 | Scene_SpawnBallsAndObjects | vmethod[32]: spawn balls (GameObject_ctor), scan SAFESPOT, CreateBadBall/MouseTrap/Secret/Flags/Signs/DynamicObjects |
| 33 | +0x84 | 0x419750 | Scene_method33 | Near Scene_dtor helper |
| 34 | +0x88 | 0x44B840 | NoOp_return | Empty stub (default) |
| 35 | +0x8C | 0x41A9A0 | Scene_ComputeInputForceDirection | Computes 3D force vector from strongest player input across balls in slot. Ghidra label "Scene_ComputeLighting" is a MISNOMER. Scale: 0.12 human, different for AI. |
SceneObject Vtable Layout (0x4D934C, 10 entries)
| Slot | Offset | Address | Name | Description |
|---|---|---|---|---|
| 0 | +0x00 | 0x46B650 | SceneObject_dtor | Destructor (sets vtable, flags cleanup) |
| 1 | +0x04 | 0x46B490 | SceneObject_SetPosition | Sets position (this+0x08..0x10) then calls vmethod+0x0C |
| 2 | +0x08 | 0x46B4B0 | SceneObject_SetScale | Sets scale (this+0x14..0x1C) then calls vmethod+0x0C |
| 3 | +0x0C | 0x46B670 | SceneObject_Render | Build world matrix from base+rotation+scale transforms, call D3D SetTransform+SetMaterial |
| 4 | +0x10 | 0x46B4D0 | SceneObject_SetVisible | Toggle visibility flag (+0x88) |
| 5 | +0x14 | - | (padding) | Non-code sentinel value |
| 6 | +0x18 | - | (padding) | Non-code sentinel value |
| 7 | +0x1C | 0x46B9F0 | SceneObject_DeletingDtor | Calls BaseDtor then free if flag&1 |
| 8 | +0x20 | 0x46B910 | SceneObject_BaseDtor | (function exists but not created) |
| 9 | +0x24 | 0x46B980 | SceneObject_vmethod9 | (function exists but not created) |
Rumble/Arena Board Initialization Functions
| Address | Name | Level Path | Special Features |
|---|---|---|---|
| 0x00413C20 | ArenaLevel_WarmUp_Init | levels\arena-WarmUp | Simple load + clone |
| 0x00413CE0 | ArenaBoard_Beginner_Init | levels\arena-beginner | 4 bumpers (N:BUMPER%d), Level_ctor + Clone |
| 0x00414180 | ArenaLevel_Intermediate_Init | levels\arena-intermediate | Simple load + clone |
| 0x00414240 | ArenaLevel_Dizzy_Init | levels\arena-dizzy | Extra Level3-Swirl loaded, no bumpers |
| 0x004144B0 | ArenaLevel_Tower_Init | levels\arena-tower | Simple load + clone |
| 0x00414960 | ArenaLevel_Up_Init | levels\arena-up | Simple load + clone |
| 0x00414B10 | ArenaLevel_Expert_Init | levels\arena-expert | Simple load + clone |
| 0x00414CE0 | ArenaLevel_Odd_Init | levels\arena-Odd | Simple load + clone |
| 0x00414F00 | ArenaLevel_Toob_Init | levels\arena-Toob | 5 bumpers (N:BUMPER%d) |
| 0x004153A0 | ArenaLevel_Wobbly_Init | levels\arena-Wobbly | Simple load + clone |
| 0x004158C0 | ArenaLevel_Sky_Init | levels\arena-Sky | PILLAR name scan via __strnicmp, append to +0x11FB |
| 0x00416080 | ArenaLevel_Master_Init | levels\arena-Master | Simple load + clone |
| 0x00416F40 | ArenaLevel_Neon_Init | levels\arena-neon | Scale matrix setup for dynamic objects, SceneObject at +0x11F9 |
| 0x00417DF0 | ArenaLevel_Glass_Init | levels\arena-glass | Simple load + clone |
| 0x00418540 | ArenaLevel_Impossible_Init | levels\arena-impossible | Simple load + clone |
Common ArenaBoard pattern: Level_ctor → Level_Clone → CameraLookAt → vmethod+0x80 (post-init). Simple arenas have just load+clone; complex ones add bumper objects, pillar scans, or SceneObject decorations.
Board (Tournament) Constructors
| Address | Name | Tournament | Sub-Levels |
|---|---|---|---|
| 0x00419030 | Board_ctor | (base) | Base tournament board constructor |
| 0x0041F4B0 | Board_ctor (Toob) | "Rodenthood" | Level8-Spinny, Level8-Saw, Level8-Fallout, Level8-Blockdawg1, Level8-Blockdawg2 |
| 0x0041D060 | LevelBoard_Dizzy_ctor | (tournament) | Tournament level 3 board |
Key SceneObject Methods
| Address | Name | Description |
|---|---|---|
| 0x0046B4F0 | SceneObject_ctor | Constructor (0xD4 bytes, vtable 0x4D934C, init pos/scale/rot matrices) |
| 0x0046B650 | SceneObject_dtor | Destructor (sets vtable, flags cleanup) |
| 0x0046B3F0 | SceneObject_Cleanup | Cleanup helper (clears lists, resets state) |
| 0x0046B4D0 | SceneObject_SetVisible | Toggle visibility at +0x88 |
| 0x0046B490 | SceneObject_SetPosition | Set position (3 floats at +0x08) + vmethod+0x0C |
| 0x0046B4B0 | SceneObject_SetScale | Set scale (3 floats at +0x14) + vmethod+0x0C |
| 0x0046B670 | SceneObject_Render | D3D world matrix build + SetTransform + SetMaterial |
| 0x0046B860 | SceneObject_BaseDtor | Iterate child AthenaList, call each dtor(1), clear list |
| 0x0046B9F0 | SceneObject_DeletingDtor | Calls BaseDtor then free if scalar deleting |
Tournament and Menu System
| Address | Name | Description |
|---|---|---|
| 0x00427080 | Tournament_AdvanceRace | Advance to next race in tournament; 15-case switch creates Board_ctor for each level (1-15), saves score, difficulty time bonus, saves race timestamps |
| 0x00428060 | App_ShowResults | Create results screen (FUN_426030 ctor), save to App+0x228, dispatch to scene manager |
| 0x0042EA30 | PracticeMenu_ctor | "Practice Menu" scene, "CHOOSE A PRACTICE RACE!", 14 race items with thumbnail textures (practice-level1.png through practice-impossible.png), lock check via App+0x851-0x865 flags |
| 0x004366E0 | Scene_SetRaceActive | Sets +0x10EC=1 (race active flag), 62 xrefs |
Tournament Level Mapping (from Tournament_AdvanceRace switch)
| Case | Level | Board Constructor | Size |
|---|---|---|---|
| 1 | Warm-Up | FUN_41CA40 | 0x436C |
| 2 | Beginner | FUN_4200E0 | 0x644C |
| 3 | Intermediate | FUN_41CB20 | 0x438C |
| 4 | Dizzy | LevelBoard_Dizzy_ctor | 0x4BE0 |
| 5 | Tower | FUN_41E340 | 0x5418 |
| 6 | Up | FUN_420390 | 0x4790 |
| 7 | Expert | FUN_424440 | 0x4394 |
| 8 | Odd | FUN_41EA40 | 0x4FD8 |
| 9 | Neon | FUN_41ED80 | 0x43B0 |
| 10 | Toob | FUN_41F4B0 | 0x646C |
| 11 | Wobbly | FUN_41F110 | 0x4388 |
| 12 | Glass | FUN_424A90 | 0x4390 |
| 13 | Sky | FUN_41F930 | 0x47F8 |
| 14 | Master | FUN_4206D0 | 0x6498 |
| 15 | Impossible | FUN_424C20 | 0x4380 |
Level Unlock Flags (App+0x851-0x865)
| Offset | Level | Texture |
|---|---|---|
| +0x851 | Dizzy | practice-level3.png |
| +0x852 | Tower | practice-level4.png |
| +0x853 | Up | practice-up.png |
| +0x854 | Expert | practice-level5.png |
| +0x855 | Odd | practice-level6.png |
| +0x856 | Toob | practice-level7.png |
| +0x857 | Wobbly | practice-level8.png |
| +0x858 | Sky | practice-level9.png |
| +0x859 | Master | practice-level10.png |
| +0x863 | Neon | practice-neon.png |
| +0x864 | Glass | practice-glass.png |
| +0x865 | Impossible | practice-impossible.png |
RNG / Random System
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x0045dd60 | RNG_Rand | 193 | PRNG with 55-entry circular buffer; returns (buf[read]+buf[write])>>6 % range |
| 0x0045dd60 | RNG_Rand(signed) | - | When param_2=1 and RNG_Rand(2)==1, returns negative value |
Graphics Transform Pipeline
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x00457b10 | Matrix44_Zero | 12 | Clear 4x4 matrix; zero all 16 entries, set diagonals to 1.0 |
| 0x00457b50 | Gfx_SetPosition | 69 | Set world position via D3D SetTransform |
| 0x00457bb0 | Gfx_RotateY | 15 | Rotate around Y axis (negate + look-at construction) |
| 0x00457c60 | Gfx_ScaleX | 40 | Scale X axis with render-state multiplier |
| 0x00457c90 | Gfx_ScaleY | 35 | Scale Y axis with render-state multiplier |
| 0x00457cc0 | Gfx_ScaleZ | 26 | Scale Z axis with render-state multiplier |
| 0x00457fd0 | Matrix4_Identity | 40 | Set identity vtable + zero (Vec3 base init) |
| 0x00425fe0 | Gfx_SetAlphaBlendState | 9 | Set D3D alpha blend render states (0xD, 0xE → mode 3) |
| 0x00427940 | Gfx_SetCullMode | 13 | Set D3D cull mode (none/CW/CCW) based on +0x708 and specular flag |
Wave / Math System
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x00457da0 | Wave_Sin | 38 | sin(time * frequency * 2π/360) |
| 0x00457dc0 | Wave_Cos | 23 | cos(time * frequency * 2π/360) |
UI List System (vtable 0x4D6A70)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x00448f20 | SimpleMenu_ctor | 15 | "Simple Menu" base ctor with item list, up/down scrollers |
| 0x004490a0 | UIListItem_ctor | - | Init 0x444-byte item with Vec3 + AthenaList |
| 0x004497f0 | UIList_AddItem | 86 | Add named item (text, subtext, colors, SceneObject, height) |
| 0x00449430 | UIList_AddSpacer | 29 | Add empty spacer item with height only |
| 0x004494d0 | UIList_ScrollUpdate | 17 | Scroll logic + mouse wheel + vtable dispatch |
| 0x00449b00 | UIList_Cleanup | 27 | Free all items (strings, SceneObjects, Vec3Lists) |
| 0x00449c20 | UIList_HandleKeyNav | 18 | Handle up/down/pgup/pgdn key navigation |
| 0x00449d40 | UIList_Render | 18 | Draw all items: gradient bars, text, icons, scroll arrows |
| 0x0044a570 | UIList_Layout | 18 | Compute text widths, position SceneObjects, set scrollers |
| 0x0044a8b0 | UIList_SetTextByName | 27 | Find item by subtext, replace display text |
| 0x00449750 | UIList_ActivateCurrentItem | 18 | Activate selected item: Back→650, Continue→50, else callback |
| 0x0044ad50 | ArenaScoreParticle_ctor | 12 | Init ArenaScoreParticle vtable + difficulty scale (0.02/0.03/0.04) |
Rumble Board System
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x004217b0 | ArenaBoard_ctor | 15 | Init board with "ArenaBoard" string, timer, base score=6000 |
| 0x00421880 | ArenaBoard_dtor | 24 | Cleanup timer, release SceneObjects, call Scene_dtor |
| 0x00421910 | ArenaBoard_Render | 16 | Draw timer bar, round info, difficulty status, "TIE BREAKER!" |
| 0x00421fe0 | ArenaBoard_Update | 16 | Check round end, resolve ties, play "Game Over" music |
| 0x00458e60 | ToggleTimer_Init | 12 | Initialize round timer |
| 0x00458e80 | ToggleTimer_Cleanup | 32 | Cleanup round timer |
| 0x00458e90 | ToggleTimer_Tick | 12 | Tick round timer countdown |
Scene / Rendering Pipeline
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x0045e0e0 | Scene_RenderAllObjects | 33 | Main render: Graphics_BeginFrame → sort objects (opaque/alpha/shadow) → draw |
| 0x00460450 | Scene_RenderBallShadow | 38 | Render ball shadow: Ball_Render + depth bias pass |
| 0x0045df80 | SceneObject_CallUpdate | 47 | Dispatch +0x434 vtable[1] (Update) |
| 0x0045df90 | SceneObject_CallRender | 47 | Dispatch +0x434 vtable[2] (Render) |
| 0x00437130 | Scene_StartCountdown | 11 | Start race countdown (3..2..1..GO with param=400 or 50 frames) |
Sprite System (vtable 0x4D8F84)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x0045d0c0 | Sprite_ctor | 30 | Init with texture, RenderContext, material defaults |
| 0x0045d660 | Sprite_RenderQuad | 15 | Render textured quad using material + draw primitive |
Dialog System
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x00440e70 | OkayDialog_ctor | 12 | "Okay Dialog" with caption text + "OKAY!" button |
Core Utility Functions
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x004531e0 | Vec3_Init | 13 | Set Vec3 vtable + zero position + w=255.0 |
| 0x00453250 | Vec3List_Free | 283 | Free Vec3List + member data at +0x103 |
| 0x004536a0 | AthenaList_GetSize | 60 | Return list count at +4 |
| 0x00453640 | AthenaList_FindByValue | - | Linear search for value, returns index or -1 |
| 0x00443990 | AthenaString_Clear | 14 | Free string buffer, reset to 15-char inline capacity |
| 0x0044b840 | Noop | 280 | Empty stub function (vtable placeholder) |
| 0x00401480 | GameObject_dtor | 9 | Release timers, free Vec3Lists, cleanup matrices |
| 0x00453610 | AthenaList_ContainsValue | 10 | Check if value exists in list (returns count>0) |
| 0x00453820 | AthenaList_MergeSorted | 10 | Merge sorted list param into this list |
| 0x004598c0 | Float_IsBetween | 10 | Test if param1 minus param2 is between two global bounds |
| 0x00469510 | AthenaString_Set | 10 | Copy string param into AthenaString (free old, strdup, set length) |
| 0x0046a140 | MusicPlayer_SetTempoScale | 10 | Set tempo scaling based on music BPM and delta time |
| 0x00477860 | FileStream_SeekRead | 10 | File stream seek and read data |
| 0x00460530 | Level_FindObjectByName | 9 | Find level game object by string name (stricmp), returns ptr or 0 |
| 0x00467d30 | Buffer_Free | 9 | Free buffer: free ptr+4, zero ptr+4/+8/+C |
| 0x00480032 | CRT startup helper | 5 | C runtime init |
| 0x00480c4d | CRT helper | 5 | C runtime helper |
| 0x0048a560 | Math/alloc helper | 5 | Runtime helper |
Session 2026-04-13 Additions — Ball Physics & Rendering
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x004015b0 | Ball_SetupCollisionRender | 4 | Initialize collision mesh render objects from level collision data |
| 0x004016f0 | Ball_ApplyForceV2 | 4 | Alternate force application (gravity plane aware, ice/dizzy/tube multipliers) |
| 0x00402c10 | Ball_RenderWithCollision | 10 | Ball render: check collision planes, render shadow, apply scaling, end frame |
| 0x00425340 | ArenaBoard_DeletingDtor | 4 | ArenaBoard destructor (calls ArenaBoard_dtor, then free) |
| 0x004280e0 | App_ShowMainMenu | 4 | Create MainMenu (0xCDC bytes) and store at App+0x224 |
| 0x004288b0 | App_StartTournamentRace | 4 | Start tournament race: config mirror/mode, create level scene, advance race |
| 0x0042c7c0 | Graphics_SetScaleAndPosition | 4 | Set identity scale matrix then set position (x,y) on Graphics object |
| 0x00443e30 | QuitDialog_ctor | 4 | Quit Dialog constructor — "Quit Dialog" with "YES"/"NO" buttons |
| 0x004468c0 | ToggleTimer_TickWrapper | 4 | Wrapper: calls ToggleTimer_Tick at offset +0x110C |
| 0x00446b80 | RegisterDialog_ValidateSerial | 5 | Validate serial number using XOR cipher with key "54138", write DXCaps to registry |
Session 2026-04-13 Additions — Register/Security Dialog
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x004475a0 | RegisterDialog_HandleInput | 4 | Handle keyboard input for register dialog (char add, length check) |
| 0x00447920 | RegisterDialog_Render | 4 | Render "REGISTER HAMSTERBALL!" screen, name/serial fields, buy button |
| 0x00448890 | RegisterDialog_HandleKey | 4 | Handle key navigation (Tab name/serial, arrows, Enter=validate, Esc=close) |
| 0x00448a40 | UITimer_dtor | 4 | UI timer destructor (set vtable, cleanup) |
| 0x00449240 | UIList_AddIconItem | 4 | UI list add item with icon (0x444-byte UIListItem, text+subtext+RGBA+icon+height) |
Session 2026-04-13 Additions — Sound System
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x00459660 | Sound_LoadOggOrWav | 10 | Load sound file: try .ogg first, then .wav fallback |
| 0x00459310 | Sound_LoadOgg | - | Load OGG Vorbis file, create D3D sound buffer, add to channel list |
| 0x00458ee0 | Sound_Play3DAtPosition | - | Play 3D positioned sound (get channel, call vtable+0x3C with position) |
| 0x00459810 | Sound_GetNextChannel | 10 | Get next sound channel from circular buffer (with wrap-around) |
Documentation Progress
| Session | Total Functions | Documented | % | New Labels |
|---|---|---|---|---|
| Initial | 3,988 | 1,700 | 42.8% | — |
| Session 1 | 3,888 | 1,768 | 45.5% | 37 |
| Session 2026-04-13a | 3,811 | 1,870 | 49.1% | 25+ |
| Session 2026-04-13b | 3,811 | 1,892 | 49.6% | 27+ |
| Session 2026-04-13c | 3,811 | 1,908 | 50.1% | 39 |
Session 2026-04-13b Additions — Core Engine & Scene Systems
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x00498200 | BitStream_ReadBits | 95 | Bitstream reader - reads N bits from byte-aligned stream with bit-level positioning |
| 0x004737f0 | AthenaString_Assign | 52 | String copy/assign operator (copies src into dest buffer) |
| 0x004792fb | D3DX_DetectShaderProfile | 48 | Detect pixel shader version (1.x/2.x/3.x) based on D3DX runtime |
| 0x00492bda | Vertex_Transform | 32 | Vertex coordinate transformation between spaces (mode 1/2/3) |
| 0x00466c70 | AthenaString_Format | 98 | String format wrapper - calls Sprintf with object's internal buffer, returns ptr |
| 0x004bbdfd | AthenaString_Sprintf | - | In-memory sprintf using FILE struct trick for vsnprintf |
| 0x00469990 | Scene_AddObject | 77 | Add SceneObject to Scene - checks uniqueness, appends, sets back-ref, vtable notify |
| 0x0046f390 | Scene_BeginFrame | 39 | Begin scene frame - Graphics_BeginFrame + vtable[6] callback |
| 0x00460da0 | Scene_RenderFrame | 38 | Full frame render pipeline - iterates scenes/objects, assigns render indices |
| 0x00461370 | Scene_RenderOpaque | 38 | Opaque render pass - iterates objects calling vtable[0x28], renders mesh entries |
| 0x00461890 | Scene_LoadMeshWorld | 38 | Load mesh world from stream - creates MeshBuffers with materials/textures/flags |
| 0x00461f00 | Scene_Subdivide | 38 | Create 3D grid of mesh objects by dividing bounding box, test visibility per cell |
| 0x00462100 | Scene_SubdivideRandom | 38 | Random grid subdivision using seeded RNG, tests cell visibility |
| 0x004629e0 | Scene_LoadCached | 37 | Load .cached scene file - materials, meshes, transforms, objects, bounding box |
| 0x0046a750 | Window_Notify | 37 | Send WM_COPYDATA (0x4A) message to window with formatted string |
| 0x004605e0 | AthenaHashTable_Lookup | 36 | Hash table case-insensitive string lookup with bucket iteration |
| 0x004691c0 | SceneObject_dtor | 31 | SceneObject destructor - frees Vec3Lists, restores base vtable |
| 0x004693c0 | Scene_AddAllObjects | 25 | Batch-add all SceneObjects from internal list |
| 0x00469600 | MWParser_ReadTag | 24 | XML/SGML tag parser - finds |
| 0x004695d0 | StreamReader_dtor | 24 | Stream/file reader destructor - close handle, free buffer |
| 0x004694f0 | Sprite_DrawColoredRect | (was 0x45d450) 23 | Draw colored rectangle with random RGBA vertex colors |
| 0x00472af0 | AthenaString_Init | 18 | Default string constructor - set vtable, zero fields, flags |
| 0x00472f30 | RegKey_Close | 18 | Close registry key handle via RegCloseKey |
| 0x00473670 | AthenaString_CopyCtor | 16 | String copy constructor from source string object |
| 0x004740d0 | AthenaString_WriteTag | 16 | Build XML tag string: |
| 0x00489610 | Pool_FreeList | 16 | Memory pool free list traversal with reference counting |
| 0x00459b24 | Graphics_InitShaderDispatch | 19 | Shader init dispatch thunk (D3DX detect + indirect jump) |
| 0x0045a439 | Graphics_SetRenderState | 29 | Render state dispatch thunk (profile detect + indirect jump) |
| 0x004ab9b8 | DivCeil | 15 | Ceiling division utility: (a-1+b)/b |
Key Data Items
| Address | Name | Description |
|---|---|---|
| 0x004D2334 | s_BACK | String "BACK" |
| 0x005341CC | g_renderIndex | Global render index counter |
| 0x004F7360 | PTR_OBJ_VTABLE | Pointer to object vtable (used by Ball, GameObject, Scene, etc.) |
Session 2026-04-13c Additions — Scene, Path, Game, Registry
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x00467bf0 | Path_GetPosition | 14 | Interpolate X/Z from path splines at time t, Y=0 |
| 0x004690f0 | Gadget_ctor | 11 | Generic Gadget constructor (vtable 0x4d9170, SceneObject-derived) |
| 0x00469240 | Gadget_Activate | 9 | Activate gadget callback (vtable[2] with RNG params) |
| 0x00457de0 | Math_Atan2Angle | 13 | Atan2 angle with quadrant adjust, degrees-per-unit scaling |
| 0x004561e0 | Material_Init | 11 | Zero-initialize 0x64-byte material/transform array |
| 0x004562b0 | Material_Copy | 8 | Copy 0x64 bytes (4x4 matrix + extras) |
| 0x00458020 | AABB_Update | 12 | Expand axis-aligned bounding box by 3D point |
| 0x00458130 | Math_FastDistance2D | 8 | Approximate integer distance (Bresenham weights 102/246) |
| 0x0045d300 | Sprite_DrawRect | 12 | Draw rect with position+size, random RGBA, 2-triangle strip |
| 0x0046b2a0 | MeshBuffer_dtor | 11 | Destroy mesh buffer list items + free Vec3List |
| 0x00472fd0 | RegKey_WriteDword | 13 | Write DWORD via RegSetValueExA |
| 0x00473080 | RegKey_ReadDword | 13 | Read DWORD via RegQueryValueExA |
| 0x00471c20 | MeshNode_ctor | 9 | Load mesh file into scene graph node (vtable 0x4d9c48) |
| 0x00472c70 | Math_Lerp | 9 | Linear interpolation: a + (b-a)*t |
| 0x0042c870 | Font_DrawCentered | 8 | Draw text centered at (x,y) position |
| 0x004351f0 | GameLevel_ctor | 8 | Game level constructor (Stands_ctor, Level_Clone, sound channel) |
| 0x00402400 | Ball_DizzyImmunity | 7 | Reset +0x2EC, update max at +0x2F4 if param exceeds current |
| 0x00425f90 | App_CompleteRace | 7 | Complete race - increment counter, trigger state transitions, clear flag |
| 0x00426b30 | String_AllocBuffer | 7 | Allocate string buffer with size |
| 0x0042b190 | ConfirmMenu_ctor | 6 | Confirmation menu (BACK/BACK2TOURNAMENT, DONE), vtable 0x4d39d0 |
| 0x00473500 | AthenaString_AssignCStr | 75 | AthenaString assign from C string (free old, alloc new, copy) |
| 0x004736b0 | AthenaString_dtor | 85 | AthenaString destructor - frees buffer, sets vtable to base dtor |
| 0x00473a50 | AthenaString_AssignCRLF | 21 | AthenaString assign CRLF ("\r\n") |
| 0x004bae43 | AthenaString_SprintfToBuffer | 71 | sprintf into char buffer via fake FILE struct |
| 0x00473050 | RegKey_WriteBool | 30 | Write boolean to registry via RegSetValueExA (REG_BINARY) |
| 0x00473130 | RegKey_ReadBool | 28 | Read boolean from registry via RegQueryValueExA |
| 0x00473170 | RegKey_ReadString | 23 | Read string from registry with fallback attempts |
| 0x00470150 | SceneObject_RenderFull | 40 | Full render with ball+material+strips, alpha-aware material application |
| 0x00470440 | SceneObject_RenderSingleObj | 39 | Render a single SceneObject with material/strip dispatch |
| 0x0046fbb0 | SceneObject_ComputeCollisionSphere | 38 | Compute bounding sphere from AABB, call Ball_CheckCollisionPlanes |
| 0x0045dfd0 | SceneObject_CheckCollision | 38 | Thunk: compute bounding sphere + check collision planes |
| 0x0049336b | D3DDevice_SetFPUControl | 29 | Set FPU control word from device state |
| 0x0049338e | Mesh_InitTexture | 32 | Initialize texture object from surface desc (D3D texture init) |
| 0x00493671 | Mesh_DrawWithTransform | 31 | Draw mesh with temporary transform override |
| 0x0049373d | Mesh_ClearColorVertices | 30 | Zero out vertices matching the clear color (transparency hack) |
| 0x0047dfb9 | Graphics_DrawIndexedPrimitive | 25 | D3D DrawIndexedPrimitive wrapper via vtable |
| 0x004a458c | longjmp_with_cleanup | 34 | CRT longjmp with optional cleanup callback |
| 0x004a45aa | seh_filter_invoke | 21 | Invoke SEH exception filter callback at offset 0x44 |
| 0x004c02e7 | LeaveCriticalSection_indexed | 23 | LeaveCriticalSection by index into global array |
| 0x0046f3b0 | Scene_BeginFrameThenRender | 39 | Begin graphics frame then invoke render callback |
| 0x0046f3d0 | MeshWorld_ctor | 39 | MeshWorld constructor from filename and strip count |
| 0x00472770 | SceneObject_BuildStrips | 39 | Builds triangle strips for SceneObject rendering |
| 0x004ba754 | __ftol2 | 358 | CRT float-to-int64 conversion (compiler intrinsic) |
| 0x004bc7c8 | __errno | 40 | CRT __errno - returns thread-local errno pointer |
| 0x004bc7d1 | __doserrno | 23 | CRT __doserrno - returns thread-local DOS errno pointer |
| 0x004bcda8 | __security_init_cookie | 34 | CRT security cookie initialization |
| 0x004bac20 | strstr | 22 | CRT strstr - string search with SIMD optimization |
| 0x004bc0d1 | strtok | 50 | CRT strtok - thread-safe string tokenizer |
| 0x00461740 | Level_ctor | 12 | Level constructor: inits base, vtable 0x4D8FB0, 4 AthenaLists, Timer + LevelState 0x10D4 bytes |
| 0x004694f0 | SceneObject_sub1_ctor | 12 | Simple SceneObject sub-ctor: set vtable 0x4D91BC, null +0x08/+0x10, visible +0x14=true |
| 0x0049721e | MeshNode_DeletingDtor | 30 | MeshNode deleting destructor: calls base dtor, then frees if param_1 & 1 |
| 0x00472c20 | AthenaHashTable_ctor | 15 | Athena hash table constructor: set vtable, call internal init, then set final vtable 0x4CF584 |
| 0x00472ec0 | RegKey_Open | 15 | Open registry key: HKLM first, fall back HKCU, create if needed |
| 0x0047df9a | Graphics_DrawIndexedPrimitiveUP | 14 | D3D DrawIndexedPrimitiveUP wrapper via vtable dispatch at offset 0x2C |
| 0x00489710 | AthenaList_IterateNext | 15 | Advance linked list iterator to next node |
| 0x00489bd0 | AthenaList_Sort_14 | 15 | Sort list elements (comparator index 0xE / 14) |
| 0x0048a1a0 | AthenaList_FreeAll | 10 | Free all list nodes: Pool_FreeList on head and items |
| 0x0048cd87 | D3D8_DebugSetMute | 14 | D3D8 DebugSetMute: load d3d8.dll/d3d8d.dll, call DebugSetMute |
| 0x004c8ff7 | _check_file_access | 14 | Check if file path is accessible (directory vs file), uses GetFileAttributesA |
| 0x004ab4a0 | __libm_sse2_tan | 14 | SSE2 tan() implementation with range reduction and polynomial approx |
| 0x004ad5ac | Zlib_FreeIf | 12 | Conditional free: only frees if both params non-null |
| 0x004ad63c | Zlib_UpdateHash | 14 | Zlib deflate hash update (calls update + insert) |
| 0x004adf0c | Zlib_FlushWithCRC | 14 | Zlib flush with CRC validation, chunked writes |
| 0x004b5ae1 | CRT_Noop2 | 14 | Empty function (no-op) |
| 0x004ba61e | CRT_ThrowStringTooLong | 10 | Throw C++ exception "string too long" |
| 0x004bacc0 | strchr | 11 | CRT strchr - find char in string with SIMD optimization |
| 0x004bc350 | strcpy | 12 | CRT strcpy - string copy with SIMD optimization |
| 0x004a167b | Matrix4x4_Multiply_SSE2 | 12 | 4x4 matrix multiply using SSE2 packed float ops |
| 0x00402a20 | Ball_SetVec3AtOffset | 12 | Set 3 floats at offset 0xca4 in ball (camera/force vector) |
| 0x00402a50 | GameObject_sub2_dtor | 12 | GameObject deleting destructor variant 2 |
| 0x00402a70 | Ball_DrawArenaScoreText | 25 | Draw rumble score text at viewport using Graphics_SetViewport + UI_DrawTextCenteredAbsolute |
| 0x00405100 | Ball_InitPhysicsDefaults | 14 | Set ball physics defaults: radius=0.5, friction=0.2, max_speed=35.0, gravity=6.0 |
| 0x00405d90 | GameObject_sub_ctor | 14 | GameObject subclass constructor: vtable 0x4CF494, scale=1.0, visibility flag, +0x80c=0x32 |
| 0x00405dd0 | GameObject_sub_dtor | 12 | GameObject deleting destructor variant 1 |
| 0x00405e00 | Ball_Update | 400+ | Main ball physics tick: timer, collision, velocity integration, reflection, sound, camera tilt, spin. Core function! |
| 0x00408390 | Ball_AI_ChaseNearest | 60 | AI steering: finds nearest opponent ball, applies force toward it, sine wave wandering fallback |
| 0x00408830 | Ball_FallUpdate | 40 | Ball update when fallen: shrinks ball, handles scale change, trail cleanup |
| 0x00408d10 | Ball_Split_ctor | 14 | Split ball constructor: vtable 0x4CF560, +0xc60=5, calls scene function |
| 0x00408d70 | Ball_Shatter | 50 | Arena: marks parent ball for despawn, spawns 3 AI split balls (IDs 1/2/4). Called from FollowBall_Update 0x43ECC0, NOT from E:JUMP |
| 0x00409480 | Ball_SplitAndExplode | 70 | Creates 2 split balls + circular ArenaScoreParticle explosion pattern (0-360 degrees) |
| 0x0040a920 | Scene_CreateGameOverMenu | 25 | Creates game-over UI based on game state: single quit, multiplayer quit, or menu mode |
| 0x0040ae50 | Sprite_DrawCentered | 12 | Sprite draw centered at position: Sprite_DrawRect with center offset |
| 0x0040b960 | SceneObj_SetBounds | 14 | SceneObject bounds setter: sets +0x14-20, identity matrix if scale != 1.0 |
| 0x0040d280 | Scene_LoadLevel2 | 15 | Load level 2: "levels\level2", clone, init scene |
| 0x0040d390 | Scene_LoadLevel3 | 25 | Load level 3: "levels\level3", clone, init scene, collect TarBubble objects |
| 0x0040d6d0 | Scene_LoadLevel4 | 20 | Load level 4: "levels\level4", clone, init scene, special setup (flag +0x80=1, camera bounds) |
| 0x00401590 | SceneObj_CallVtable18 | 8 | SceneObject vtable call at offset 0x18 |
| 0x00401a60 | Vec3_Length | 8 | Vec3 length: sqrt(xx + yy + z*z), min 1.0 |
New Functions (Session 2026-04-13)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4531b0 | Vec3_SetScalar | 6 | Set all 3 components to same value, w=1.0 |
| 0x4536b0 | AthenaList_InsertAt | 7 | Insert value at index, reallocate array |
| 0x453e90 | Gfx_TransformX | 7 | Screen Y transform: y * viewMatrix + viewport |
| 0x453eb0 | Gfx_TransformY | 7 | Screen Z transform: z * viewMatrix + viewport |
| 0x453970 | Graphics_SetCullMode2 | 5 | Set D3D cull mode via vtable call |
| 0x44aca0 | SimpleList_vtbl_Init | 7 | SimpleList vtable pointer init |
| 0x44b850 | UIListItem_vtbl_Init | 6 | UIListItem vtable pointer init |
| 0x426d20 | StdString_Erase | 6 | String erase: delete substring at position |
| 0x426de0 | StdString_Substr | 5 | String substring extraction |
| 0x426f30 | PlayerProfile_ctor | 5 | Player profile constructor (0x280 bytes) |
| 0x443a10 | StdString3_Clear | 5 | Clear string, free heap buffer |
| 0x443f50 | GameObject2_dtor | 5 | GameObject destructor variant |
| 0x44fda0 | TourneyMenu_ctor | 6 | Tournament menu constructor |
| 0x458220 | AABB_TriangleIntersect | 6 | AABB vs triangle edge intersection test |
| 0x458320 | AABB_TriangleTest6Edges | 6 | Full 6-edge AABB-triangle intersection |
| 0x457f10 | Collision_PointInTriangle | 5 | Barycentric point-in-triangle test |
New Functions (Session 2026-04-13 Part 2 - Vec3/Ball/Scene)
| Address | Name | Description |
|---|---|---|
| 0x00401090 | Scene_SetSoundMode | Set scene audio mode: dispatches vtable[50](0x16, mode), stores at +0x708 |
| 0x00401100 | Gfx_PackColorRGB | Pack 3 float RGB channels to 24-bit color via __ftol2 + shift/OR |
| 0x004011c0 | SceneObj_SetScale | Set uniform scale factor on SceneObject, create scale matrix |
| 0x00401220 | Scene_ZoomIn | Increase scene viewport zoom by g_zoomStep, call SetProjection |
| 0x00401270 | Scene_ZoomOut | Decrease scene viewport zoom by g_zoomStep, call SetProjection |
| 0x00401640 | Timer_dtor | Timer deleting destructor: calls Timer_Cleanup, optionally frees |
| 0x00401660 | Ball_SetName | Set ball display name at +0xC28, copies string, sets type ID=200 |
| 0x00402030 | Ball_SetTargetPos | Set/interpolate target position with smooth damping threshold |
| 0x00402150 | Ball_CheckProximity | Check position proximity radius, store integer distance result |
| 0x00402200 | Ball_Shrink | odd race shrink ball: set is_shrunk+0xC4C=1, shrink radius to 13.0, play 3D sound |
| 0x00402270 | Ball_Grow | odd race grow ball: clear is_shrunk, set radius=26.0, physics=5.0 |
| 0x00402290 | GameObject_Render | Render game obj: scale, depth layer toggle, Sprite_RenderQuad, copy result |
| 0x004027f0 | Ball_dtor | Ball deleting destructor: calls Ball_Cleanup, optionally frees |
| 0x004029c0 | CollisionMesh_SetSpeed | DEAD CODE. Writes roll_friction (+0xC64) and scaled_dir (+0xC98), immediately overwritten by Ball_Update Phase 14. Does NOT control ball speed. |
| 0x457b80 | Gfx_SetPositionAndRender | 5 |
| 0x458f10 | Vtable_CallOffset48 | 7 |
| 0x473940 | FontFormatString_Parse | 9 |
| 0x4749b0 | StdString_CompareSubstr | 8 |
| 0x47c830 | StreamReaderVtbl_Init | 8 |
| 0x489660 | D3DX_SurfaceClipBlit | 9 |
| 0x489da0 | Pool_Reset | 8 |
| 0x48a820 | D3DX_Uninit | 8 |
| 0x42e220 | DifficultyMenu_ctor | 5 |
| 0x42e840 | OptionsMenu_RenderControls | 5 |
| 0x42f550 | OptionsMenu_dtor | 5 |
| 0x4363f0 | Platform_ctor | 5 |
| 0x465260 | Level_LoadCollision | 6 |
| 0x465d90 | Mesh_FindClosestCollision | 10 |
| 0x465ef0 | Collision_TraverseSpatialTree | 3 |
| 0x492bc4 | D3DX_AssemblyOp8 | 8 |
| 0x498200 | BitStream_ReadBits | 95 |
New Functions (Session 2026-04-13 Continued)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4531b0 | Vec3_SetScalar | 6 | Set all 3 components to same value, w=1.0 |
| 0x4536b0 | AthenaList_InsertAt | 7 | Insert value at index, reallocate array |
| 0x453e90 | Gfx_TransformX | 7 | Screen Y transform: y * viewMatrix + viewport |
| 0x453eb0 | Gfx_TransformY | 7 | Screen Z transform: z * viewMatrix + viewport |
| 0x453970 | Graphics_SetCullMode2 | 5 | Set D3D cull mode via vtable call |
| 0x44aca0 | SimpleList_vtbl_Init | 7 | SimpleList vtable pointer init |
| 0x44b850 | UIListItem_vtbl_Init | 6 | UIListItem vtable pointer init |
| 0x426d20 | StdString_Erase | 6 | String erase: delete substring at position |
| 0x426de0 | StdString_Substr | 5 | String substring extraction |
| 0x426f30 | PlayerProfile_ctor | 5 | Player profile constructor (0x280 bytes) |
| 0x443a10 | StdString3_Clear | 5 | Clear string, free heap buffer |
| 0x443f50 | GameObject2_dtor | 5 | GameObject destructor variant |
| 0x44fda0 | TourneyMenu_ctor | 6 | Tournament menu constructor |
| 0x458220 | AABB_TriangleIntersect | 6 | AABB vs triangle edge intersection test |
| 0x458320 | AABB_TriangleTest6Edges | 6 | Full 6-edge AABB-triangle intersection |
| 0x457f10 | Collision_PointInTriangle | 5 | Barycentric point-in-triangle test |
| 0x463790 | Vec3_CrossProduct | 7 | Vector cross product: this x param2 -> param1 |
| 0x46b1a0 | AthenaListObj_ctor | 7 | AthenaList object constructor |
| 0x4738b0 | StdString_AppendN | 7 | Append N chars from string with strncat |
| 0x47c840 | Sprite_CalcTexCoords | 8 | Calculate sprite UV coordinates |
| 0x4602f0 | Scene_CollectByNameFilter | 6 | Collect scene objects by name filter |
| 0x46ec30 | Ball_GetInputForce | 7 | Get ball input force (keyboard/mouse/joystick) |
| 0x46f670 | Mesh_SaveAndFree | 5 | Save mesh to file, free buffers |
| 0x473b10 | AthenaString_AssignFormatted | 6 | Format and assign AthenaString |
| 0x46a440 | Audio_PlayMusicAtSpeed | 6 | Play music with tempo/speed adjustment |
| 0x466ac0 | Scene_UpdateChildren | 5 | Traverse scene tree, update children |
| 0x467750 | Array_CopyDWords | 5 | Copy N dwords from src to dst |
| 0x429520 | Game_SetInProgress | 5 | Mark game in progress (sets +0x200=1) |
| 0x440dd0 | Graphics_DrawRectAndReset | 5 | Draw rect, then reset matrix to identity |
| 0x44be80 | ScoreObject_ctor | 5 | SceneObject constructor (vtable PTR_RaceGoalReached_Render) — creates object used for race goal rendering and rotator ball tracking |
| 0x44fd40 | SimpleList_dtor | 5 | SimpleList destructor |
| 0x459610 | Scene_RenderIfVisible | 5 | Render scene object if visible flag set |
| 0x470650 | Array_FillDWords | 5 | Fill N dwords with same value |
| 0x472da0 | Transform_ctor | 5 | Transform object constructor |
| 0x418760 | Scene_CreateObject_Gear | 6 | Create GEAR object in scene |
| 0x418930 | Gear_AdvanceAlongPath | 6 | Gear path following (8-dir gradient descent) |
| 0x4730c0 | Registry_ReadFloat | 5 | Read float from Windows registry |
| 0x473740 | StdString_AppendCharN | 5 | Append N copies of a char |
| 0x46e0b0 | Input_IsKeyDown | 5 | Check input state (keyboard/mouse/joystick) |
| 0x456150 | Ray_SetDirection | 3 | Set ray direction, normalize, compute length |
| 0x456390 | Mesh_Clear | 3 | Clear mesh: free vertex/index buffers |
| 0x456e80 | Font_WordWrap | 3 | Word-wrap text to fit width |
| 0x458000 | Collision_InitDefaultAABB | 3 | Initialize default collision AABB bounds |
| 0x4583f0 | AABB_TriangleIntersect2 | 4 | Double AABB-triangle test wrapper |
| 0x4581d0 | Vec2_Distance | 3 | 2D distance: sqrt(dx²+dy²) |
| 0x458190 | Collision_GradientEval_Stub | 4 | Empty stub for collision gradient evaluation |
| 0x46f340 | MeshBuffer_Cleanup | 3 | Cleanup mesh buffer, free resources |
| 0x46fd60 | Mesh_AddVertex | 4 | Add vertex to mesh buffer (8 floats) |
| 0x46c970 | Texture_SetDimensions | 3 | Set texture dimensions and UV scale |
Menu System (Session 2026-04-13 Batch 4)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x42f810 | TimeTrialMenu_ctor | 3 | "Time Trial Menu" → extends PracticeMenu with race items + lock checks |
| 0x42fc10 | PartyMenu_ctor | 3 | "Time Trial Menu" ( Party Race! ) → extends PracticeMenu, vtable 0x4D4738 |
| 0x42fc40 | ArenaMenu_ctor | 3 | Arena menu with 14 arena items (Warm-Up to Impossible), lock icons, vtable 0x4D47B8 |
| 0x4326d0 | MPMenu_ctor | 3 | Multiplayer menu: Party Race, Rodent Rumble, controller config (1-4P) |
Scene/Texture/Rendering (Session 2026-04-13 Batch 4)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x44aa40 | Scene_FindTextureByName | 4 | Find texture by case-insensitive name, return ptr+dimensions+width |
| 0x44ab00 | Scene_FindTextureDimensions | 4 | Find texture and measure text width via Font_MeasureText |
| 0x44abf0 | Scene_AddTextureToList | 4 | Add texture reference to scene list by name, mark dirty at +0xCBC |
| 0x443ac0 | SceneObject_RenderScaled | 3 | Render object scaled (ScaleX, SetPosition, vtable calls + Timer) |
| 0x4410c0 | SceneObject_FreeStrings | 3 | Free 2 string pointers, re-init BaseObject, call SceneObject_dtor |
| 0x453c50 | Texture_RemoveRef | 3 | Decrement texture refcount, remove from cache and free when 0 |
| 0x457a50 | Graphics_DisableRenderState | 3 | Thunk → Graphics_SetRenderState (disable mode) |
Game Flow (Session 2026-04-13 Batch 4)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x428c50 | App_StartPracticeRace | 3 | Start practice/tournament race: calls App_StartRace, sets up PlayerProfile, calls Tournament_AdvanceRace |
| 0x434580 | Sound_InitChannels | 3 | Allocate sound channels for object, get next sample, play 3D positioned sound, set timer 0x140 |
| 0x43b6f0 | Rotator_AddBall | 3 | Register ball on rotator tracking list (8-byte entry [ball_ptr, tick=10]). Resets tick to 10 if ball already tracked. Formerly misnamed ScoreObject_SetScore. Called from N:ONROTATOR, N:SPINNY, N:SWIRL. |
| 0x43e600 | Catapult_Update | 4 | Per-frame update: decrements tick counters, applies rotation matrix to tracked balls' position (+0x164/+0x168/+0x16C) and velocity (+0xCA4/+0xCA8/+0xCAC). Frees entries when counter reaches 0. Shared by catapult launch and rotator/gear systems. |
| 0x43e9c0 | Catapult_AddObjectConditional | 3 | Register ball on catapult/gear tracking list (guarded by +0x1510). Same 8-byte entry pattern as Rotator_AddBall. Called from N:ONGEAR. |
| 0x434290 | Catapult_Launch | 2 | Launch pad activation: sets catapult+0x10F0=1 (active), +0x10F4=50 (launch timer). Called on E:CATAPULTBOTTOM. |
| 0x44bef0 | Timer_Decrement | 4 | Timer tick: value = end_value - 100, set flag at +0x2A |
| 0x448620 | ScoreDisplay_DeletingDtor | 3 | ScoreDisplay scalar deleting destructor |
| 0x4470d0 | ScoreDisplay_dtor | 3 | Clean up ScoreDisplay: free strings, timers, BaseObjects (5), SceneObject_dtor |
| 0x44acb0 | UIList_Clear | 4 | Empty stub (returns 0), clear/reset UI list |
Board Level Constructors (Session 2026-04-13 Batch 5)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x41CA40 | LevelBoard_WarmUp_ctor | 1 | Board constructor for Warm-Up race (Level 1), calls Board_ctor, sets "Board (Warm-Up)", loads BEGINNERRACE data |
| 0x41CB10 | LevelBoard_WarmUp_dtor | 1 | Board destructor for Warm-Up race, sets vtable to 0x4D04A8, calls Scene_dtor |
| 0x41CB20 | LevelBoard_Intermediate_ctor | 1 | Board constructor for Intermediate race (Level 2), loads Level2-Bridge, creates level clone, TipperVisual_Attach |
| 0x41CC80 | LevelBoard_Intermediate_dtor | 1 | Board destructor for Intermediate race |
| 0x41D450 | LevelBoard_Dizzy_dtor | 1 | Board destructor for Dizzy race (Level 3), frees Vec3Lists at +0x11E4 and +0x10DE |
| 0x41E340 | LevelBoard_Tower_ctor | 1 | Board constructor for Tower race (Level4), loads 6 levels: Level4-Catapult, Level4-Drawbridge, Level4-Mace, Level4-Windmill, Level4-Turret, plus YellowLink and Chomper meshes |
| 0x41E640 | LevelBoard_Tower_dtor | 1 | Board destructor for Tower race, frees Vec3Lists at multiple offsets |
| 0x41EA40 | LevelBoard_Expert_ctor | 1 | Board constructor for Expert race (Level5), loads Level5-Bridge, creates clone, 3x hammyjudge meshes |
| 0x41EC90 | LevelBoard_Expert_dtor | 1 | Board destructor for Expert race |
| 0x41ED80 | LevelBoard_Odd_ctor | 1 | Board constructor for odd race (Level6), sets "Board (Odd)", ODDRACE data |
| 0x41EE70 | LevelBoard_Odd_dtor | 1 | Board destructor for odd race |
| 0x41F110 | LevelBoard_Wobbly_ctor | 1 | Board constructor for Wobbly race (Level 12), loads 7 levels: Level7-Wobbly1 through Level7-Wobbly7 |
| 0x41F3C0 | BoardLevel12_Wobbly_dtor | 1 | Board destructor for Wobbly race |
| 0x41F720 | BoardLevel_Toob_dtor | 1 | Board destructor for Toob race |
Collision System (Session 2026-04-13 Batch 5)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x456D80 | CollisionMesh_ctor | 1 | CollisionMesh constructor: initializes triangle list for mesh collision detection, sets up AthenaLists |
| 0x456120 | CollisionMesh_AddTriangle | 1 | Add triangle to collision mesh: appends to list at +0x430, sets back-pointer at +0x08 |
| 0x4564C0 | Ball_AdvancePositionOrCollision | 1 | Ball physics: advance position with collision detection. Uses spatial tree for mesh traversal, handles material accumulation, collision response. Core physics function. |
| 0x463330 | SpatialTree_ctor | 1 | SpatialTree (octree) constructor for collision spatial partitioning |
| 0x4632E0 | SpatialTree_Free | 1 | Free spatial tree nodes and cleanup |
CRT/Misc
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4C5F5A | CRT_amsg_exit | 10 | CRT error message exit (__amsg_exit) |
Newly Documented Functions (Session 2026-04-13)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x440390 | Scene_UpdateArenaPhysics | 1 | Arena physics update: wave motion, collision detection, sound triggers |
| 0x466cf0 | CollisionNode_ctor | 1 | Collision node constructor: sets friction values (0.1), vtable |
| 0x477020 | CollisionNode_BaseInit | 1 | Collision node base initialization |
| 0x459aba | Triangle_Interpolate2D | 1 | 2D triangle interpolation (barycentric coords) |
| 0x453c00 | Graphics_InitShaderProfileThunk | 1 | Shader profile init thunk |
| 0x441150 | UI_CheckKeyCombo | 2 | UI key combo handler (calls vtable on press) |
| 0x41f7e0 | Scene_HandleRaceEnd_ClampZoom | 1 | Clamps camera zoom levels after race end |
| 0x430330 | PauseArenaMenu_ctor | 2 | Pause Rumble menu constructor (RESUME, OPTIONS, QUIT) |
| 0x4601a0 | Scene_MarkDirty | 2 | Recursively marks scene objects as dirty (tree traversal) |
| 0x470680 | MeshWorld_ClearObjectLists | 3 | Clears AthenaLists on marked objects during cleanup |
| 0x474e70 | Menu_dtor | 1 | Menu destructor: frees 10 Vec3List entries, clears AthenaLists |
| 0x475000 | Menu_DeletingDtor | 1 | Menu scalar deleting destructor |
| 0x441190 | OkayDialog_Render | 2 | Okay dialog render: draws gradient bars, "OKAY!" button |
| 0x4415b0 | UI_SetQuarterViewport | 1 | Sets quarter-screen viewport dimensions (50x50) |
| 0x446710 | UI_ConfirmYes | 1 | UI confirm handler ("YES" dialog response) |
| 0x424c10 | ArenaBoard_DeletingDtor | 1 | ArenaBoard scalar deleting destructor |
| 0x460220 | Scene_ResetObjectSlots | 1 | Resets scene object slots, re-registers objects |
| 0x453100 | Rect_ContainsPoint | 1 | Point-in-rectangle test (AABB containment) |
| 0x4415f0 | UI_ResetViewportToQuarter | 1 | Resets viewport to quarter-screen (4x 0x32 values) |
| 0x442540 | UI_SetPauseRightButtonText | 1 | Sets "PAUSE W/RIGHT BUTTON: %s" text in UIList |
| 0x4431c0 | UIList_DeletingDtor | 1 | UIList scalar deleting destructor |
| 0x441660 | UIList_dtor | 1 | UIList destructor: initializes 5 AthenaHashTables, cleanup |
| 0x4502f0 | ArenaBoard_Menu_dtor | 1 | ArenaBoard menu destructor: vtable, timer, UIList cleanup |
| 0x445230 | Scene_StartTournament | 1 | Starts tournament mode: creates TourneyMenu, sets music tempo |
| 0x446730 | Tourney_SaveTournament | 1 | Saves tournament to "DATA\tournament.sav" |
| 0x450960 | Tourney_AdvanceRound | 1 | Advances tournament to next round, creates new TourneyMenu |
| 0x4508f0 | Tourney_SetCurrentLevel | 1 | Sets tournament current level, material color |
| 0x4610e0 | MeshWorld_CollectTrianglesInAABB | 3 | Recursive AABB triangle collection (collision mesh query) |
| 0x467730 | Array_Fill | 1 | Simple array fill (memset-like) |
| 0x468050 | Vector_Init | 1 | Vector/array initializer (allocates dword array) |
| 0x469090 | SpatialNode_SwapBuffers | 1 | Spatial tree node buffer swap (copy/move semantics) |
| 0x4707b0 | MeshObject_dtor | 1 | Mesh object destructor: releases textures, frees mesh data |
| 0x471c00 | MeshObject_DeletingDtor | 1 | MeshObject scalar deleting destructor |
| 0x475020 | MeshWorld_AddTexture | 2 | Adds texture to mesh world (creates 0x48 byte texture object) |
| 0x476009 | Bit_ShiftByte | 1 | Bit shift helper for byte values |
| 0x4471e0 | StdString_Replace | 1 | String replace operation (std::string-like) |
| 0x448410 | UI_TextEdit_PasteFromClipboard | 2 | Pastes text from Windows clipboard to UI |
| 0x4492d0 | UIList_AddItem | 1 | Adds item to UIList (creates UIListItem, copies string) |
| 0x441640 | SceneObject_DeletingDtor | 1 | SceneObject scalar deleting destructor |
| 0x4722e0 | AthenaVector_Init | 1 | Athena vector/list initializer |
| 0x473030 | Registry_SetValue | 4 | Windows registry value setter (RegSetValueExA wrapper) |
| 0x451b90 | TourneyMenu_ctor | 1 | Tournament menu constructor: difficulty, music, scoring thresholds |
| 0x462380 | SpatialTree_CloneToLevel | 2 | Recursively clones spatial tree to Level object |
| 0x456870 | Mesh_DeletingDtor | 1 | Mesh scalar deleting destructor |
| 0x479000 | Sound_EnumerateDevices | 1 | Sound device enumeration callback registration |
| 0x480032 | D3DTexture_ResizeAndValidate | 5 | D3D texture resize and compression format validation |
Session 17 - SSE2/Math/Texture/AthenaString/Gfx Functions
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4b22ab | BitStream_ReadBitsSSE2 | 19 | SSE2 bitstream reader: reads N bits from compressed stream, skips 0xFF markers |
| 0x4a0f3a | Matrix_Inverse4x4_SSE2 | 8 | SSE2 4x4 matrix inverse using cofactor expansion + Newton-Raphson reciprocal |
| 0x4a6b80 | SSE2_SetFPControlWord | 9 | Sets FPU control word from param, stores to global DAT_00535280 |
| 0x4aba49 | Mem_Zero | 8 | Optimized memset-to-zero: dword loop + byte tail |
| 0x4ad576 | Malloc_OrLongjmp | 9 | Safe malloc wrapper, longjmps on failure with "Out of Memory" |
| 0x4bc360 | StrCat_Fast | 9 | Optimized strcat with dword-aligned null detection (0x7efefeff trick) |
| 0x4b8564 | BitStream_CopyToOutput | 8 | Bitstream copy-to-buffer with checksum callback and double-buffer |
| 0x4c183e | Noop2 | 8 | Empty no-op function stub |
| 0x4c7152 | ReturnZero | 8 | Stub function returning 0 |
| 0x459d96 | D3DX_ShaderDispatch0 | 3 | D3DX shader dispatch: calls D3DX_DetectShaderProfile then vtable[0x4F7194] |
| 0x459e34 | D3DX_ShaderDispatch1 | 4 | D3DX shader dispatch via PTR 0x4F71B8 |
| 0x459ed1 | D3DX_ShaderDispatch2 | 4 | D3DX shader dispatch via PTR 0x4F71CC |
| 0x467e40 | AthenaList_Ctor | 4 | AthenaList constructor: sets vtable 0x4E998C, calls StdString_Substr |
| 0x46a0d0 | Audio_StopChannel | 4 | BASS_ChannelStop on channel at this+0x08 |
| 0x46b840 | SceneObject_EmptyListCtor | 3 | Sets vtable to SceneObject_DeletingDtor(0x4D9368), inits AthenaList |
| 0x46db10 | App_Shutdown | 4 | App shutdown: destroys window, releases 5 COM objects, CoUninitialize |
| 0x46dfa0 | NetworkConnection_Ctor | 3 | NetworkConnection init: sets "Not Connected", +0x0C=1.0f |
| 0x472990 | Gadget_LabelCtor | 4 | UI label widget ctor: Gadget+label, vtable 0x4D9E68, name at +0x878 |
| 0x69b20 | UIWidget_HitTest | 3 | UI hit-test: checks +0x420 rect or iterates children for point containment |
| 0x73480 | AthenaString_Reserve | 4 | String reserve: allocates new buffer, copies old string, free old |
| 0x73600 | AthenaString_Length | 3 | Recalculates string length from buffer, caches at +0x14 |
| 0x73640 | AthenaString_Find | 4 | strstr wrapper: finds substring, returns offset or -1 |
| 0x77010 | D3DX_RegistryGetter | 4 | Sets vtable from DirectX registry path struct at 0x4DA65C |
| 0x75dc0 | CRC32_Compute | 3 | CRC32 using 256-entry lookup table at 0x4F7534 |
| 0x77670 | Vec3_ClosestPointOnLine | 3 | Projects point onto line segment, clamps t, returns nearest point |
| 0x77970 | Texture_StreamRead | 3 | Texture streaming read with 1KB chunks and progress callback |
| 0x77ac0 | Texture_BinarySearch | 4 | Texture binary search for load position optimization |
| 0x77d60 | Texture_LoadFromStream | 4 | Texture load from stream with D3D validation and retry |
| 0x78680 | Texture_dtor | 4 | Texture destructor: frees sub-textures, pixel buffers, refcount release |
| 0x45ace5 | D3DX_ShaderDispatch_4 | 4 | D3DX shader dispatch with 4 params via PTR 0x4F7214 |
| 0x45ad75 | D3DX_ShaderDispatch_4b | 4 | D3DX shader dispatch with 4 params via PTR 0x4F71F8 |
| 0x45ae05 | D3DX_ShaderDispatch_2a | 4 | D3DX shader dispatch with 2 params via PTR 0x4F7208 |
| 0x45aea1 | D3DX_ShaderDispatch_2b | 4 | D3DX shader dispatch with 2 params via PTR 0x4F720C |
| 0x45af3e | D3DX_ShaderDispatch_2c | 4 | D3DX shader dispatch with 2 params via PTR 0x4F7210 |
| 0x45afdc | D3DX_ShaderDispatch_3 | 4 | D3DX shader dispatch with 3 params via PTR 0x4F7238 |
| 0x45b521 | D3DX_ShaderDispatch_noarg | 4 | D3DX shader dispatch with no args via PTR 0x4F71FC |
| 0x45b7a8 | D3DX_ShaderDispatch_noarg2 | 4 | D3DX shader dispatch via PTR table |
| 0x45baeb | D3DX_ShaderDispatch_noarg3 | 4 | D3DX shader dispatch via PTR table |
| 0x45bc10 | D3DX_ShaderDispatch_noarg4 | 4 | D3DX shader dispatch via PTR table |
| 0x45bf20 | D3DX_ShaderDispatch_noarg5 | 4 | D3DX shader dispatch via PTR table |
| 0x8a160 | Texture_SetPool | 6 | Sets texture pool on struct (calls init then assigns param) |
| 0x8a180 | Pool_dtor | 6 | Pool destructor: frees pool list, zeroes 8 dwords |
| 0x8a560 | Texture_ValidateOrBuild | 5 | Validates texture generation or rebuilds from source data |
| 0x8a860 | Texture_DestroyBuffers | 5 | Destroys indexed buffer list (frees each entry then array) |
| 0x8a8d0 | Mesh_Init | 3 | Mesh initializer: zeroes 8 dwords, callocs 0xCA8 byte sub-struct |
| 0x8a900 | Mesh_dtor | 6 | Mesh destructor: frees vertex/index/shader buffers, zeroes 8 dwords |
| 0x89df0 | Pool_Free | 4 | Frees two pool lists and zeroes 0x14 dwords |
| 0x89e20 | Texture_ComputeChecksum | 4 | Iterates byte stream summing values, marks 0x80000000 on non-0xFF terminator |
| 0x8b1c0 | Pool_Alloc | 6 | Pool allocator: 8-byte aligned alloc with linked-list overflow |
| 0x8b2a0 | VertexDeclaration_dtor | 3 | Vertex declaration destructor: frees buffer at +0x4C, zeroes 0x18 dwords |
| 0x8b530 | VertexShader_dtor | 3 | Vertex shader destructor: frees vertex/index buffers, releases DX objects |
| 0x80c4d | Gfx_ResizeBuffers | 5 | D3D render target resize: recreates vertex/index buffers, validates device |
| 0x472340 | Font_RenderToTextureComplex | 4 | Complex font rendering to texture with vertex buffers and shaders |
Session 18 - ArenaBoard/BoardLevel Scalar Destructors
| Address | Name | Description |
|---|---|---|
| 0x425360 | ArenaBoard_CollSlices_scalar_dtor | Scalar deleting destructor for CollSlices board |
| 0x425380 | ArenaBoard_CollSlices_dtor | Destructor for CollSlices board |
| 0x4253e0 | ArenaBoard_Expert_ScalarDtor | Scalar deleting destructor for Expert Arena board |
| 0x425400 | ArenaBoard9_PopCylinder_ScalarDtor | Scalar deleting destructor for ArenaBoard9 PopCylinder |
| 0x425420 | BoardLevel14_RaceOfAges_Scene_scalar_dtor | Scalar deleting destructor for RaceOfAges scene |
| 0x425440 | ArenaBoard_Odd_ScalarDtor | Scalar deleting destructor for Odd Arena board |
| 0x425460 | BoardLevel_Glass_scalar_dtor | Scalar deleting destructor for Glass board level |
| 0x425480 | ArenaBoard_ScalarDtor | Scalar deleting destructor for ArenaBoard |
| 0x4254c0 | ArenaBoard_Level_ScalarDtor | Scalar deleting destructor for ArenaBoard level |
Session 18 - CreditsScreen, MusicPlayer, TourneyMenu
| Address | Name | Description |
|---|---|---|
| 0x425920 | CreditsScreen_dtor | CreditsScreen destructor |
| 0x425ac0 | CreditsScreen_Render | CreditsScreen render/layout |
| 0x425d80 | CreditsScreen_Layout | CreditsScreen layout calculation |
| 0x425f70 | CreditsScreen_scalar_dtor | CreditsScreen scalar deleting destructor |
| 0x426030 | MusicPlayer_ctor | MusicPlayer constructor |
| 0x4260c0 | MusicPlayer_dtor | MusicPlayer destructor |
| 0x426130 | MusicPlayer_scalar_dtor | MusicPlayer scalar deleting destructor |
| 0x426150 | MusicPlayer_Render | MusicPlayer render/update |
| 0x4264a0 | TourneyMenu_GetRaceName | Get race name string for tournament |
| 0x4264b0 | TourneyMenu_WriteSave | Write tournament save data |
| 0x4265a0 | TourneyMenu_LoadSaveAndShow | Load tournament save and display |
| 0x4266f0 | TourneyMenu_Advance | Advance tournament to next round |
| 0x426780 | TourneyMenu_CreateBoard | Create board for tournament race |
| 0x426af0 | TourneyRaceEntry_scalar_dtor | TourneyRaceEntry scalar deleting destructor |
| 0x431d00 | TourneyRaceEntry_scalar_dtor2 | TourneyRaceEntry alternate scalar deleting destructor |
| 0x432d20 | TourneyMenu_Render | TourneyMenu render function |
Session 18 - AthenaString
| Address | Name | Description |
|---|---|---|
| 0x426bae | AthenaString_MoveAssign | AthenaString move assignment operator |
| 0x426c50 | AthenaString_Reserve | AthenaString buffer reserve/capacity |
Session 18 - Pendulum/Rotator/CollisionFace
| Address | Name | Description |
|---|---|---|
| 0x434030 | Rotator_PlayCollisionSound | Play collision sound for Rotator |
| 0x434070 | Rotator_Render | Rotator render function |
| 0x4362c0 | Pendulum_Cleanup_vtable | Pendulum vtable cleanup thunk |
| 0x4362d0 | Pendulum_Render | Pendulum render function |
| 0x436390 | Pendulum_AddIndex | Add index to Pendulum object |
| 0x436530 | Rotator_Cleanup_vtable | Rotator vtable cleanup thunk |
| 0x4366f0 | Rotator_ctor | Rotator constructor |
| 0x4367d0 | Rotator_Cleanup | Rotator cleanup/release |
| 0x4367e0 | Rotator_StartSound | Rotator start sound playback |
| 0x436860 | CollisionFace_ctor | CollisionFace constructor |
| 0x436910 | CollisionFace_dtor_vtable | CollisionFace vtable destructor thunk |
| 0x436920 | Rotator_ctor_sound | Rotator constructor (sound variant) |
| 0x436a10 | Rotator_Cleanup_vtable2 | Rotator vtable cleanup thunk variant 2 |
| 0x436a20 | Pendulum_ctor | Pendulum constructor |
| 0x436b10 | Pendulum_Cleanup | Pendulum cleanup/release |
| 0x436b70 | Pendulum_PlayCollisionSound | Play collision sound for Pendulum |
| 0x436c10 | Rotator_ctor_nosound | Rotator constructor (no sound variant) |
| 0x436ce0 | Rotator_Cleanup_vtable3 | Rotator vtable cleanup thunk variant 3 |
| 0x436cf0 | Rotator_TriggerSound | Rotator trigger/start sound |
| 0x436e40 | Pendulum_scalar_dtor | Pendulum scalar deleting destructor |
| 0x436e60 | Pendulum_Cleanup_vtable2 | Pendulum vtable cleanup thunk variant 2 |
| 0x436e70 | Pendulum_ResetAndFire | Reset and fire Pendulum |
| 0x436fb0 | Rotator_Cleanup_vtable4 | Rotator vtable cleanup thunk variant 4 |
| 0x436fc0 | Rotator_RemoveAndFree | Remove and free Rotator object |
| 0x4371a0 | Rotator_Cleanup_vtable5 | Rotator vtable cleanup thunk variant 5 |
| 0x4371f0 | Rotator_MarkTriggered | Mark Rotator as triggered |
| 0x4372f0 | Rotator_Cleanup_vtable6 | Rotator vtable cleanup thunk variant 6 |
Session 18 - Level_Cleanup Variants
| Address | Name | Description |
|---|---|---|
| 0x433f00 | Level_Cleanup_vtable1 | Level vtable cleanup thunk variant 1 |
Session 21 - Scene Rendering, Mesh, Font, Huffman, D3DTexture, Sound, CRT
| Address | Name | Description |
|---|---|---|
| 0x0045ec30 | Scene_Render3DObjects | Main 3D object renderer: transforms vertices, frustum culls, submits triangle strips with D3D draw calls |
| 0x00468600 | Path_ComputeSegmentLengths | Compute segment lengths from point pairs using sqrt(dx²+dy²) |
| 0x0045afdc | D3DX_ShaderThunk_Profile1 | Thunk: calls D3DX_DetectShaderProfile(1) then indirect via PTR_FUN_004f7238 |
| 0x0045b521 | D3DX_ShaderThunk_Profile1b | Thunk: calls D3DX_DetectShaderProfile(1) then indirect via PTR_FUN_004f71fc |
| 0x0045bf20 | D3DX_ShaderThunk_Profile1c | Thunk: calls D3DX_DetectShaderProfile(1) then indirect via PTR_FUN_004f71e8 |
| 0x0047c990 | Mesh_Dtor | Mesh destructor: set vtable, free ptrs +0x20/+0x28/+0x30, clear AthenaList, free Vec3Lists |
| 0x0047d160 | Mesh_FindElement | Search array of 8-float elements (stride 0x424) for matching entry, returns index or -1 |
| 0x0047d2a0 | Mesh_AddElement | If not found, append 8 floats + 0 at stride 0x424 and increment count |
| 0x0047d020 | Mesh_ConnectElements | Build adjacency between two mesh elements (param_2, param_3) via edge list |
| 0x0047d680 | FileHandle_Dtor | File handle destructor: close handle, free buffer |
| 0x00479be0 | App_GetProductVersion | Uses Version API (GetFileVersionInfoSizeA/A/VerQueryValueA) to extract ProductVersion string |
| 0x00478780 | BitStream_ReadValue | Read int64 from bitstream array with stride 0x10; can sum all values |
| 0x004ab9c8 | Math_AlignUp | Round up: ((a-1+b)/b)*b — align value to multiple of second arg |
| 0x004ab9e0 | Array_CopyElements | Copy param_5 elements of param_6 bytes each from src to dst array-of-pointers |
| 0x004abfaf | DSound_SetVolume | Set volume on sound object via vtable call (opcode 0x35) |
| 0x004ad569 | CRT_FreeIfNotNull | Free ptr if non-null, simple null-check wrapper |
| 0x004b2101 | Huffman_BuildTable | Build canonical Huffman decode table from 16-byte code length table (deflate-style) |
| 0x004b2a13 | BitStream_FlushAndReset | Flush accumulated bits, zero the history buffer, reset position, set timestamp |
| 0x004b670c | Sound_DecodeFrame | Decode a sound frame: handles types 4 (stop note), 5 (callback), 6 (sub-decode) |
| 0x0048300c | D3DTexture_Init | Create D3D texture object with vtable PTR_004db360, init format/flags, register with parent |
| 0x00483a44 | D3DTexture_InitLocked | Same as Init but with locked vtable PTR_004db3d0 (render target variant) |
| 0x00486f30 | D3DTexture_NullDtor | Trivial destructor that just sets vtable to PTR_LAB_004db4c8 |
| 0x004b0e6b | CRT_FreeParam2 | Callback wrapper: just frees param_2 (2nd arg), ignores param_1 |
| 0x004ad716 | Font_DecodeGlyphBits | Decode glyph bitmap from compressed font data based on bit depth (1/2/4/8) |
Session 21 (continued) - Level ctors/dtors, more naming
| Address | Name | Description |
|---|---|---|
| 0x004383f0 | Glass_Level_scalar_dtor | Glass level scalar deleting destructor |
| 0x004384a0 | Glass_Level_ctor | Glass level constructor: Stands init, clone level, init timers, lookup Chain1/2 Bridge/Wall hashtables |
| 0x00438730 | Impossible_Level_scalar_dtor | Impossible level scalar deleting destructor |
| 0x00438830 | Cascade_Level_scalar_dtor | Cascade level scalar deleting destructor |
| 0x00438b10 | WarmUp_Level_scalar_dtor | WarmUp level scalar deleting destructor |
| 0x00438f30 | Tower_Level_scalar_dtor | Tower level scalar deleting destructor |
| 0x004396f0 | Spinner_Level_ctor | Spinner level constructor: Stands init, position/velocity, timer, scale X/Z, clone level, 100 score, 1.0 factor |
| 0x00439850 | Intermediate_Level_scalar_dtor | Intermediate level scalar deleting destructor |
| 0x00439870 | Impossible_Level_Update | Impossible level update: countdown timer, gravity, scale/position, sound |
| 0x00439b90 | Sawblade_Level_scalar_dtor | Sawblade level scalar deleting destructor |
| 0x0043a150 | Gear_Level_ctor | Gear level constructor: RNG for speed/offset, 400ms time, ScoreDisplay init |
| 0x00430430 | TourneyRaceEntry_Dtor | TourneyRaceEntry destructor: set vtable, call UIList_Cleanup |
Session 21 (continued 2) - Level dtors, UI, String, RaceResults
| Address | Name | Description |
|---|---|---|
| 0x00446860 | QuitToDesktop_scalar_dtor | QuitToDesktop scalar deleting destructor |
| 0x00446880 | GameObject2_scalar_dtor | GameObject2 scalar deleting destructor |
| 0x004468a0 | TourneyContinueDialog_scalar_dtor | TourneyContinueDialog scalar deleting destructor |
| 0x004469e0 | App_CreateConfirmMenu | Create ConfirmMenu dialog, add to scene |
| 0x00446a60 | App_CreateHighScoreEntry | Create HighScoreEntry dialog, add to scene |
| 0x00446ae0 | QuitToDesktop_Execute | Execute quit: set cull mode, start race, show results, show main menu |
| 0x00447370 | ScoreDisplay_scalar_dtor | ScoreDisplay scalar deleting destructor |
| 0x00447390 | StdString_Insert | Insert count bytes from ptr at position in std::string (SSO-aware) |
| 0x00447500 | StdString_TruncateToWidth | Erase chars from end until Font_MeasureText width <= 0x13F (319px) |
| 0x00447570 | StdString_InsertCStr | Insert C string at position (strlen + call StdString_Insert) |
| 0x0044af60 | RaceResults_scalar_dtor | RaceResults scalar deleting destructor |
| 0x0044af80 | RaceResults_Init | RaceResults init: set vtable, Matrix_Identity |
| 0x0044b8a0 | RaceResults_ctor | RaceResults constructor: init timers, random congratulatory text, score thresholds |
| 0x0044bfc0 | RaceResults_Render | Render race results: colored rect, shadow text for title/place/time |
| 0x0044c450 | RaceResults_Reset | Reset: free sub-object, cleanup timer, restore vtable |
| 0x0044c7d0 | RaceResults_Update | Update race results: advance timers, check completion, sound/voice |
| 0x0044ca80 | RaceResultsMenu_scalar_dtor | RaceResultsMenu scalar deleting destructor |
| 0x0044cb10 | RaceResultsMenu_ctor | RaceResultsMenu constructor: title, subtitle, player entries, timer |
Session 22 - Path/Sprite/Gfx/Math Functions
| Address | Name | Description |
|---|---|---|
| 0x00457370 | FontList_Dtor | FontList destructor: set vtable PTR_004d8e30, iterate/remove textures, clear list, null 0x100 entries, Vec3List_Free |
| 0x0045cb88 | Matrix_BuildRotationZYX | Build 4x4 rotation matrix from Euler ZYX angles (sin/cos with global factor 0x4d5c84) |
| 0x0045d8f0 | Ball_RenderWithMaterial | Render ball with material: Ball_Render, Graphics_ApplyMaterialAndDraw, vtable 0x120 call |
| 0x0045dfe0 | Gfx_SetupAlphaRenderState | Setup alpha render state: cull mode, texture stage 0x152, alpha test 0x16, blend 0x1d/7/0x89, texture 0x1b |
| 0x0045dcf0 | RNG_SeedSmall | Seed small RNG: 53-entry additive PRNG with state at +0xc (mask 0x3fffffff), 0x35 iterations |
| 0x0045dde0 | Gfx_SetBlendState | Set D3D blend state: vtable 0xfc calls (0, 0xd, 2) and (0, 0xe, 2), state check at +0x704 |
| 0x0045d030 | Sprite_Reset | Sprite reset: set vtable PTR_004d8f84, remove texture ref, Matrix_Identity |
| 0x0045d0a0 | Sprite_ScalarDtor | Sprite scalar destructor: calls Sprite_Reset, then free if flag bit 0 set |
| 0x0045d1d0 | Sprite_Ctor | Sprite constructor: vtable PTR_004d8f84, RenderContext_Init, sets 3 scale pairs from image dims |
| 0x0045dab0 | Sprite_DrawRotatedQuad | Draw rotated quad: 5-point star pattern via sin/cos waves around center, 4 RGBA corner colors |
| 0x00468780 | Path_BuildVertexStrips | Build vertex strips from path array: interleaved pos/uv/color quads, stride-7 format, vertex count = (count-1)*4 |
| 0x00468f30 | Path_ComputeSegDeltas | Compute segment deltas from path: outputs 4-float {0,0,delta,start} per segment |
| 0x00469580 | FileHandle_Open | Open file: close prev handle, free buffer, _open with 0x8000 flags, set status flag |
| 0x00467c60 | Array_CopyDWordsThunk | Thunk: just calls Array_CopyDWords |
| 0x00467e00 | DualBuffer_Free | Free dual buffer: frees two pointers at +0x14 and +0x04, zeros 6 DWORDs |
| 0x00468510 | PathGroup_Init | PathGroup init: zero 15 DWORDs across 5 groups of 3 |
| 0x00466cd0 | Transform_SetDefaultScale | Set transform default scale: NoOp + write 0.1f at +0xc and +0x10 |
| 0x00466d50 | Transform_ScalarDtor | Transform scalar destructor: calls sub_466cc0 then free if flag bit 0 set |
| 0x00467780 | Array_CopyBackward | Copy DWORDs backward: src→dest in reverse, count in bytes |
| 0x004677b0 | Matrix_SolveGaussElim | Gaussian elimination with partial pivoting: solves Ax=b for matrix at param_1 |
| 0x00467cc0 | Array_FillAndAdvance | Fill DWORD array and advance: Array_FillDWords, returns ptr + count |
| 0x00467cf0 | Vector_InsertRange | Insert range into vector: copy [param_2, param_3) into this container, update end ptr |
| 0x004685e0 | PathGroup_PushPair | PathGroup push pair: Vector_PushBack of 4 then 8 onto two vectors offset by 0x10 |
| 0x004692d0 | SceneObject_ScalarDtor | SceneObject scalar destructor: calls SceneObject_dtor then free if flag bit 0 set |
Session 22 (continued) - App Lifecycle, Input, Gfx Lighting
| Address | Name | Description |
|---|---|---|
| 0x0046c050 | App_CreateInputDevice | Allocate 0x91c bytes, call FUN_00466620 (input device ctor), store at App+0x178 |
| 0x0046c0b0 | App_CreateAudioDevice | Allocate 0x424 bytes, call Audio_Init, store at App+0x17c |
| 0x0046c170 | App_FrameUpdate | Frame update: GetCursorPos, WindowFromPoint, poll input/audio/collision, GameUpdate |
| 0x0046c200 | App_ResetFrame | Reset frame: Scene_ResetCameraAndFrameCount, then Graphics_Clear |
| 0x0046c260 | App_TickGameUpdate | Thunk: call FUN_00469a60(App+0x184) - tick game update |
| 0x0046c290 | App_OnMouseDown | Mouse down handler: SetCapture, set button flags, UIWidget_HitTest, dispatch to widget |
| 0x0046f100 | Gfx_ApplyLightingState | Set D3D lighting state: specular enable, light enable, material emission, 0x39 state |
| 0x0046f1e0 | Gfx_ResetLighting | Reset lighting: disable specular, set render state 0x1b=0, material type 0, state 0x39 |
| 0x0046ca20 | App_ParseGUID | Parse GUID from string: MultiByteToWideChar + CLSIDFromString, store at this+0x44 |
| 0x0046dc20 | App_ScalarDtor | App scalar destructor: calls App_Shutdown then free if flag bit 0 |
| 0x0046dc40 | App_Ctor | App constructor: vtable, size 640/480, cursors, CoInitialize, input device 0x848 |
| 0x0046e910 | KeyboardDevice_ScalarDtor | KeyboardDevice scalar destructor: calls KeyboardDevice_dtor then free |
| 0x0046ebd0 | InputDevice_PollAndRelease | Poll DInput device, acquire on error, release 4 sub-devices |
| 0x00471c60 | Vector_FillResize | Fill vector with count DWORDs, update end pointer |
Session 23 — Math, Sound, D3D, Stream, Spatial (74.4→75.2%)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4637f0 | Vec3_Abs | 2 | Compute absolute value of 3-component vector (fabs each) |
| 0x45b345 | Matrix_BuildLookAt | 1 | Compute view matrix from eye, target, up vectors (cross products) |
| 0x45b48d | Matrix_BuildPerspectiveFOV | 1 | Compute perspective projection matrix from FOV, aspect, near, far |
| 0x45b1f9 | D3D_Thunk4 | 1 | D3D dispatch thunk through PTR_FUN_004f7230 (4 params) |
| 0x45b227 | D3D_Thunk0 | 1 | D3D dispatch thunk through PTR_FUN_004f722c (0 params) |
| 0x45b238 | D3D_Thunk5 | 1 | D3D dispatch thunk through PTR_FUN_004f7234 (5 params) |
| 0x45be69 | D3D_ThunkIndirect | 1 | D3D dispatch thunk through DAT_004f71e4 |
| 0x457690 | Font_DrawGlyph3D | 1 | Render 3D text — iterate chars, render with Ball_RenderWithMaterial |
| 0x476270 | D3DX_ErrorHandler_Ctor | 1 | Init D3DX error handler — set vtable 0x4DA194 + store param |
| 0x466060 | Level_CloneTree | 1 | Recursively clone Level tree — create CollisionLevel for each child |
| 0x466320 | SoundChannel_Ctor | 1 | Init sound channel vtable 0x4D90E8 with 7 zero fields |
| 0x466480 | SpatialTree_ComputeOffset | 1 | Compute vector offset with cross-product area for spatial tree |
| 0x4664f0 | Sound_LoadAndAppend | 1 | Create AthenaList entry, load sound via Sound_LoadOggOrWav |
| 0x466620 | SoundDevice_ctor | 1 | Sound device constructor — vtable 0x4D911C, 16 channels, DS init |
| 0x466a10 | SoundDevice_UpdateChannels | 1 | Tick sound channels, play on timer expire, remove expired |
| 0x466b80 | SoundDevice_Play3DAll | 1 | Iterate all sound channels, call Sound_Play3DAtPosition per entry |
| 0x466c50 | SoundDevice_DeletingDtor | 1 | Scalar deleting destructor for SoundDevice |
| 0x466c90 | SoundBuffer_ScalarDtor | 1 | Free buffer at +4, scalar deleting destructor |
| 0x466cc0 | D3DX_RegistryGetter_Ctor | 1 | Init D3DX registry getter — set vtable 0x4D9128 |
| 0x468700 | D3DXSkinMesh_Init | 1 | Initialize D3DX skin mesh — two vector resizes |
| 0x4632f0 | SpatialTree_SetDefaults | 1 | Set spatial tree defaults: scale=0.1, depth=6, min=0.9 |
| 0x4633b0 | SpatialTree_DeletingDtor | 1 | Scalar deleting destructor — free then delete |
| 0x463880 | SpatialTree_ForEach | 1 | Iterate tree children calling vtable[0x20] on each |
| 0x4694a0 | StreamReader_DeletingDtor | 1 | Scalar deleting destructor calling StreamReader_dtor |
| 0x4694c0 | StreamReader_dtor | 2 | Free 2 buffers at +4/+8, set vtable 0x4D91B8 |
| 0x4698d0 | FileStream_DeletingDtor | 1 | Close file handle +0x10, free buffer +0x8 |
| 0x469920 | MeshWorld_ctor_v2 | 1 | MeshWorld constructor v2 — vtable 0x4D91C4 |
| 0x4699d0 | MeshWorld_RemoveObject | 1 | Remove object from mesh list, clear current references, call dtor |
| 0x469a40 | MeshWorld_ClearCurrent | 1 | Clear current item by calling vtable+0x30 |
| 0x469a80 | MeshWorld_CallNext | 1 | Call vtable+0x24 on next item at +0x424 |
| 0x469ac0 | MeshWorld_SkipOrAdvance | 1 | Skip current (param_2!=0) or advance to next if matches param_1 |
| 0x469c30 | MeshWorld_dtor2 | 1 | Clear list, iterate items calling dtor(1), Vec3List_Free |
| 0x469ec0 | MeshWorld_ActiveUpdate | 1 | Iterate objects, skip inactive (+0xB), set obj ID, call vtable+8 |
| 0x469f50 | MeshWorld_CallVtable34 | 1 | Iterate objects calling vtable+0x34 |
| 0x469fc0 | MeshWorld_DeletingDtor2 | 1 | Scalar deleting destructor for MeshWorld variant |
| 0x469fe0 | MusicChannel_Ctor | 1 | Init vtable 0x4D91D8, AthenaList, volume=1.0, flags=0 |
| 0x46a180 | MusicChannel_FadeUpdate | 1 | Fade BASS volume up/down with BASS_ChannelSetAttributes |
| 0x46a250 | MusicChannel_Cleanup | 1 | Free all channel buffers, clear AthenaList, Vec3List_Free |
| 0x46a4b0 | MusicChannel_DeletingDtor | 1 | Scalar deleting destructor for MusicChannel |
Session 25 - Mesh/Vertex/D3D thunks, Array utilities (63.8%)
| Address | Name | Description |
|---|---|---|
| 0x0047ead8 | DynArray_Grow | Dynamic array grow: realloc with doubled capacity |
| 0x0047ebae | FindInSmallIntArray | Search small int array for matching value |
| 0x00480c4d | MeshBuffer_Allocate | Allocate mesh vertex/index buffer |
| 0x00489217 | SetFileSecurityW | IAT thunk for SetFileSecurityW |
| 0x0048a560 | AthenaList_SortMerge | AthenaList merge sort implementation |
| 0x0048a860 | MeshGroup_dtor | MeshGroup destructor |
| 0x0048ce30 | TextureCache_RecursiveFree | TextureCache recursive free (tree traversal) |
| 0x0048eb20 | BuildSDFGrid | Build signed distance field grid |
| 0x004912e4 | ComputeScanlineZBuffer | Compute scanline Z-buffer for software rendering |
| 0x00496e13 | Mesh_SetVertexFormat | Set mesh vertex format/declaration |
| 0x0045b72c | D3DX_Thunk3Param | D3D dispatch thunk through PTR_FUN_004f7204 (3 params) |
| 0x0047e026 | WideString_MatchSlot | Match ushort value against 3-slot array, return index (0/1/2) |
| 0x0047e152 | WideArray_Grow | Realloc wchar array with capacity doubling |
| 0x0047e359 | ShortArray_FindMatch3 | Search short array of size 3 for matching value |
| 0x0047e5bd | Mesh_BuildAttributeIndices | Build mesh attribute/index remapping tables (vertex→attrib, 0xFFFF sentinel) |
| 0x0047e725 | Mesh_CollectUniqueAttributes | Collect unique attribute IDs from mesh into dynamic array (doubles capacity) |
| 0x0047e855 | UshortArray_PushBack | Push ushort element with doubling capacity |
| 0x0047e914 | Mesh_RemapVertexIndices | Remap vertex/adjacency indices via lookup tables (stride 0xC) |
| 0x0047ea05 | Mesh_SwapVertices | Swap two mesh vertices + adjacency/attribute/blend data |
| 0x0047eb76 | MeshData_Init | Init mesh data struct: set 3 params, zero counts at +0x18/+0x2C |
| 0x0047ec49 | Mesh_SwapVertexData | Swap vertex data between indices using alloca temp buffer (stride from +0x2C) |
| 0x0047edc2 | MeshSubMesh_Init | Init sub-mesh struct: set 3 params, zero counts at +0x10/+0x20 |
| 0x0047ee1b | MeshEdge_GetFirstVertex | Get first valid vertex from 4-entry edge list (+0x14 base offset) |
| 0x0047ef1e | MeshAttribute_FindIndex | Search attribute short array for matching value, return found+index |
| 0x0047f02a | Mesh_UnlinkVertex | Unlink vertex from doubly-linked adjacency list (0xC stride) |
| 0x0047f17e | Mesh_LinkVertexToHead | Link vertex to head of hash bucket (stride-6 linked list) |
| 0x0047f363 | VertexDecl_CopyVertexData | Copy vertex data between declarations: blend weights, position, normal, texcoords |
| 0x0047f58e | VertexDecl_CreateFromFVF | Create vertex declaration from FVF (Flexible Vertex Format) code |
| 0x0047e263 | DWord6Array_Grow | Grow DWORD-6-stride array with realloc (init 0x28 bytes, security cookie) |
Session 25 (continued) - Mesh Pipeline Functions
| Address | Name | Description |
|---|---|---|
| 0x0047f6c7 | Mesh_RenderSubset | Render mesh subset by attribute ID via D3D DrawIndexedPrimitive (HW or SW vertex processing) |
| 0x0047f993 | Mesh_WeldVertices | Weld duplicate vertices: build adjacency hash, split edges, remap indices, fill adjacency |
| 0x0047fdc0 | Mesh_RemoveEdgeFromHash | Remove edge entry from hash bucket linked list (find matching v0/v1/v2, unlink) |
| 0x0047fe0e | Mesh_FindBestDuplicateVertex | Find best matching duplicate vertex by comparing cross-product magnitudes with epsilon |
| 0x0047feec | Mesh_ComputeTriCrossProduct | Compute scalar triple product of two 3D triangles for vertex welding comparison |
| 0x00480501 | Mesh_ValidateAdjacency | Validate mesh adjacency: check face refs, vertex bounds, edge integrity, attribute matching |
Session 25 (continued 2) - Mesh Pipeline, Rendering, Validation
| Address | Name | Description |
|---|---|---|
| 0x00480501 | Mesh_ValidateAdjacency | Validate mesh adjacency: check face refs, vertex bounds, edge integrity, attribute matching |
| 0x004806da | Mesh_DrawSubsetIndexed | Draw mesh subset by attribute ID using D3D DrawIndexedPrimitive (indexed path) |
| 0x00480813 | Mesh_WeldVertices16 | 16-bit variant of WeldVertices: build adjacency, weld duplicate verts, split edges |
| 0x00481101 | Mesh_ValidateAdjacency16 | 16-bit variant of ValidateAdjacency: check 16-bit face/edge/vertex refs |
| 0x0048130c | Mesh_ValidateAttributes | Check attribute IDs match face ranges in attribute table |
Session 25 (continued 3) - Mesh Copy, Adjacency, Optimize
| Address | Name | Description |
|---|---|---|
| 0x00481432 | Mesh_CopyFrom | Copy mesh data from source: indices, adjacency, attributes, vertices, blend data |
| 0x00481637 | Mesh_CopyFrom16 | 16-bit index variant of Mesh_CopyFrom (ushort adjacency stride 6) |
| 0x0048183d | Mesh_AdjacencyIteratorNext | Advance adjacency iterator across mesh edges (32-bit), handle boundary/interior |
| 0x00481900 | Mesh_AdjacencyIteratorNext16 | 16-bit variant of Mesh_AdjacencyIteratorNext |
| 0x00482010 | Mesh_OptimizeFaces | Optimize mesh face order for vertex cache, compact vertex remapping |
Session 25 (continued 4) - Mesh Optimization Pipeline
| Address | Name | Description |
|---|---|---|
| 0x00482137 | Mesh_OptimizeVertices | Vertex cache optimization: swap/reorder vertices for cache locality, compact index remap |
| 0x00482218 | Mesh_SortByAttribute | Sort mesh by attribute ID: build attr table, remap vertices/faces, D3DX ID3DXMesh Optimize style |
Session 25 (continued 5) - Mesh Helper Functions
| Address | Name | Description |
|---|---|---|
| 0x0047dffd | FindInSmallIntArray32 | Find int value in 3-element int array, return index or (2+not_found) |
| 0x0047e8f6 | Mesh_RemapVertexAttribute32 | Remap vertex attribute through lookup table (if flag bit 2 set at +0x0C) |
| 0x0047e96b | Mesh_SwapVertices32 | Swap vertex data between two indices using alloca temp buffer (32-bit stride) |
Session 25 (continued 6) - Vec/Math, Level, Sound, FlagWaver, App, Input, Vector STL
| Address | Name | Description |
|---|---|---|
| 0x0045c17b | Vec2_Normalize | Normalize 2D vector (length-squared check, divide by sqrt) |
| 0x0045c32f | Vec3_Normalize | Normalize 3D vector (length-squared check, divide by sqrt) |
| 0x0045c208 | Matrix_TransformVec4x3 | Transform vector by 4x3 matrix (last row implicit [0,0,0,1]) |
| 0x0045c48e | Gfx_ProjectToViewport | Project 3D coords to viewport UV using render state flags |
| 0x0045c61b | Gfx_UnprojectToNDC | Unproject screen coords back to normalized device coords via viewport rect |
| 0x0045c7c1 | D3D_ThunkShaderDispatch4 | D3D thunk dispatch with 4 params |
| 0x0045caae | Matrix_BuildOuterProductScale | Build 4x4 matrix from outer product of vec scaled and subtracted from identity |
| 0x00461460 | SceneObject_BaseInit | Base init for scene objects — AthenaList_Init, Vec3_Init, string buffer |
| 0x00461680 | SceneObject_BaseClear | Clear/reset scene object base — Vec3List_Free, Matrix_Identity, free string |
| 0x004629c0 | Level_DeletingDtor | Scalar deleting destructor for Level (calls Level_Cleanup, free if flag) |
| 0x004650e0 | Level_dtor | Level destructor — set vtable 0x4D9068, call Level_Cleanup |
| 0x00465240 | Level_DeletingDtor2 | Scalar deleting destructor variant 2 for Level |
| 0x00465860 | Level_LoadMeshes | Create MeshWorld, create MeshBuffers, parse (NOCOLLIDE)/N:/E: prefixes, create CollisionLevels |
| 0x00466570 | Level_ReadSoundVolume | Read "Sound Volume" float from registry, default 1.0 |
| 0x004665e0 | Audio_ClampPanValue | Convert float to int pan value, clamp minimum at -2000/-10000 |
| 0x004668a0 | SoundDevice_dtor | SoundDevice destructor — save volume to registry, free AthenaLists, vec3list |
| 0x00467b40 | Spline_EvalCubic | Evaluate cubic spline (t^3a + t^2b + t*c + d), clamp t to array bounds |
| 0x00467c30 | Array_CopyDWords3 | Copy dwords from begin to end into destination |
| 0x00467c80 | Array_CopyDWords4 | Thunk to Array_CopyDWords |
| 0x00467d60 | MultiBuffer_Free | Free 6 allocated buffers with size/capacity fields at 0x10-byte intervals |
| 0x00467ea0 | Exception_DeletingDtor | Scalar deleting destructor for std::exception |
| 0x00467ec0 | Exception_dtor | Exception destructor — free SSO string, call base ~exception |
| 0x00467f00 | Exception_AssignCStr | Exception constructor from C string — StdString_Assign |
| 0x00467f40 | Exception_ThrowVectorLength | Throw "vector |
| 0x00467fc0 | Exception_CopyCtor | Exception copy constructor — base copy + StdString_Substr |
| 0x004680b0 | Vector_InsertN | STL vector::insert — insert N elements at position (realloc/memmove) |
| 0x00468350 | Vector_Assign | STL vector::assign — copy from another vector, realloc if needed |
| 0x00468490 | Vector_Resize | STL vector::resize — grow or trim to count |
| 0x00468570 | Vector_PushBack | STL vector::push_back — append element, realloc via InsertN if needed |
| 0x00469a60 | UIWidget_CallVtable20 | Call vtable+0x20 on object at +0x424 if non-null |
| 0x00469aa0 | UIWidget_CallVtable28 | Call vtable+0x28 on object at +0x424 if non-null |
| 0x00469b20 | UIWidget_HitTest | Find widget under point — check rect bounds, iterate back-to-front |
| 0x00469be0 | UIWidget_UpdateHover | Update hover — find widget under point, call vtable+0x30 leave / vtable+0x2c enter |
| 0x0046a0e0 | RegKeyList_AppendStr | Allocate string+value pair, append to AthenaList at +0xC |
| 0x0046a3c0 | RegKeyList_CopyFromSibling | Iterate sibling list entries, call RegKeyList_AppendStr for each |
| 0x0046a6e0 | RaptisoftUtil_Ctor | Constructor — FindWindowA("Raptisoft Utility"), set vtable |
| 0x0046a7f0 | RaptisoftUtil_DeletingDtor | Destructor — Window_Notify "*** END RAPTISOFT SESSION ***" |
| 0x0046a820 | FlagWaver_dtor | Free vertex buffer, release D3D resource, identity matrix |
| 0x0046a8a0 | FlagWaver_AllocBuffers | Allocate vertex buffer array, create D3D vertex buffer |
| 0x0046a930 | FlagWaver_AdvancePhase | Increment wave phase offset at +0x28 |
| 0x0046a940 | FlagWaver_DeletingDtor | Scalar deleting destructor for FlagWaver |
| 0x0046a960 | FlagWaver_UpdateVertices | Compute water wave positions (sin/cos), normals, vertex averaging for smooth surface |
| 0x0046af30 | FlagWaver_Ctor | Constructor — vtable 0x4D9344, 10 segments, wave params, alloc buffers |
| 0x0046b070 | FlagWaver_Render | Render water ripple — update vertices, test ball intersection, draw with vertex buffer |
| 0x0046b200 | RenderList_AppendCopy | Allocate new RenderContext and copy data, or append existing to list |
| 0x0046b360 | RenderList_FreeAndClear | Iterate list calling dtor(1), then AthenaList_Free |
| 0x0046b3d0 | MeshBuffer_DeletingDtor | Scalar deleting destructor for MeshBuffer |
| 0x0046bca0 | App_WriteDisplaySettings | Write Fullscreen/ScreenWidth/ScreenHeight to registry |
| 0x0046bd00 | App_ReadDisplaySettings | Read Fullscreen/ScreenWidth/ScreenHeight from registry |
| 0x0046bff0 | App_CreateGraphics | Allocate Graphics object (0x7DC), store at App+0x174 |
| 0x0046c110 | App_CreateInputHandler | Allocate InputHandler (0x438), store at App+0x180 |
| 0x0046c3c0 | Input_OnMouseUpCapture | Decrement capture count, ReleaseCapture if zero, notify interceptor "MouseUp" |
| 0x0046c430 | Input_OnMouseUp | ReleaseCapture, notify interceptor or widget with vtable+0x18, clear button flags |
| 0x0046c760 | Input_OnMouseDown | Hit-test widget, call vtable+0x1c with button param |
| 0x0046c7c0 | App_SetFullScreen | Toggle fullscreen/windowed, Graphics_Reset, AdjustWindowRect, SetWindowPos |
| 0x0046c9e0 | App_FrameTick | If not paused, render + update scene |
| 0x0046cb00 | App_CreateScoreDisplay | Allocate 0x8A4 score display object, add to scene |
| 0x0046cb70 | App_SetTitleString | Free and replace title string at App+0x1B4 |
Session 25 (cont7) — 44 renames — 67.5% documented
| Address | Name | Description |
|---|---|---|
| 0x0046f010 | SceneObject_dtor | Destroys scene object, iterates list calling dtor(1), frees sub-objects |
| 0x0046f0e0 | SceneObject_DeletingDtor | Deleting dtor for SceneObject |
| 0x0046f310 | SpriteAnim_Ctor | Initializes sprite animation with vtable 004d9c48 |
| 0x0046f7c0 | SpriteAnim_SetRange | Sets animation range (prev=current, new=param) |
| 0x0046f7d0 | SpriteAnim_InitFromCalcTexCoords | Initializes sprite anim from texture coords (8 calls) |
| 0x0046f8d0 | MeshWorld_BuildVertexBuffer | Builds vertex buffer from mesh object lists, creates D3D VB |
| 0x0046fb50 | SpriteAnim_DeletingDtor | Deleting dtor for SpriteAnim, frees callback and data |
| 0x0046fcc0 | MeshWorld_CollectRenderLists | Collects render lists from mesh objects into target list |
| 0x0046ff50 | SafeCallDtor | Calls vtable[2] (dtor) on object ptr if non-null |
| 0x0046ff60 | MeshWorld_OptimizeAll | Optimizes all meshes, builds vertex buffers |
| 0x00471ce0 | Throw_VectorTooLong | Throws "vector |
| 0x00472570 | MeshWorld_BuildFontMeshes | Builds font meshes via Font_RenderToTextureComplex |
| 0x00472a30 | ArenaBoard_dtor | ArenaBoard destructor, frees sub-object and calls SceneObject_dtor |
| 0x00472a50 | ArenaBoard_TickDown | Decrements countdown, triggers vtable callbacks at zero |
| 0x00472a80 | ArenaBoard_RenderThenFree | Calls render (vtable+0x48) then free (vtable+0x40) |
| 0x00472ad0 | ArenaBoard_DeletingDtor | Deleting dtor for ArenaBoard |
| 0x00472b20 | HitBox_PointInBounds | AABB point-in-bounds check with float comparisons |
| 0x00472b80 | D3DTexture_Ctor | Initializes D3D texture object with vtable 004d9ecc |
| 0x00472c00 | D3DTexture_DeletingDtor | Deleting dtor for D3D texture |
| 0x00472d80 | BaseObject_DeletingDtor | Deleting dtor for BaseObject |
| 0x00472e20 | AthenaHashTable_DeletingDtor | Deleting dtor for hash table (calls ctor then frees) |
| 0x00472ea0 | RegKey_Ctor | Initializes registry key object with vtable 004d9f08 |
| 0x00472f50 | RegKey_SetSoftwarePath | Builds "SOFTWARE%s" registry path |
| 0x00473000 | RegKey_WriteDWORD | Writes DWORD value to registry via RegSetValueExA |
| 0x00473100 | RegKey_QueryValue | Queries registry value via RegQueryValueExA |
| 0x00473220 | RegKey_DeletingDtor | Deleting dtor for RegKey, frees path string |
| 0x00473260 | eSellerate_ExtractDLL | Extracts eSellerateEngine.dll from resources to Windows dir |
| 0x00473355 | eSellerate_ExtractDLLNull | Calls eSellerate_ExtractDLL with NULL module |
| 0x0047335d | eSellerate_ReadAffiliateKey | Reads affiliate key from SOFTWARE\eSellerate registry |
| 0x00473460 | StdString_FreeBuffer | Frees internal buffer, zeroes capacity/size/ptr |
| 0x00473480 | StdString_Reserve | Reallocates buffer to param_1+1 size |
| 0x00473580 | StdString_AssignN | strncpy-assigned string with length param |
| 0x00473600 | StdString_RecalcLen | Recalculates string length if dirty flag set |
| 0x00473640 | StdString_FindSubstr | strstr wrapper returning index or -1 |
| 0x004736f0 | AthenaString_AssignCStrFree | Assign C string then free source |
| 0x00473a70 | AthenaString_AssignCStrFree2 | Variant of assign-CStr-then-free |
| 0x00473ac0 | AthenaString_AssignFree | Assign string then free source |
| 0x00473ba0 | AthenaString_Substr | Substring extraction by index/length |
| 0x00473cd0 | AthenaString_EraseRange | Erase characters in range |
| 0x00473e20 | AthenaString_EraseSubstr | Find and erase substring |
| 0x00474000 | AthenaString_Truncate | Truncate string to N characters |
| 0x004742b0 | MWParser_DumpTags | Dump tags from MW parser to string |
| 0x004743f0 | MusicChannel_LoadAndAppend | Create MusicChannel, load file, append to list |
| 0x00474480 | MusicDevice_SetVolume | Set BASS config volume |
| 0x004744b0 | MusicDevice_ReadVolume | Read "Music Volume" from registry |
| 0x00474510 | MusicDevice_MuteToggle | Toggle BASS mute on/off |
| 0x004746a0 | MusicDevice_dtor | Destroy music device, free channels, BASS_Stop/Free |
| 0x00474780 | MusicDevice_FadeAll | Update fade on all music channels |
| 0x004747e0 | MusicDevice_DeletingDtor | Deleting dtor for MusicDevice |
| 0x00474800 | Menu_Ctor | Menu class constructor, inits 8 AthenaLists |
| 0x00474900 | LoaderGadget_OK | "LoaderGadget::OK" - handles OK button |
| 0x00474930 | Menu_SetDirty | Sets dirty flag (+0x2d64) |
| 0x00474940 | Menu_MergeAllLists | Merge all 7 category lists into main list |
| 0x00474a30 | LoaderGadget_Tick | Load progress tick, updates count/percentage |
Session 26 — 30 renames — 69.1% documented
| Address | Name | Description |
|---|---|---|
| 0x00473e70 | AthenaString_ReplaceAt | Replace substring at position with new string |
| 0x00473fb0 | AthenaString_ReplaceSubstr | Find substr, erase, insert replacement at same position |
| 0x00475cec | StrLen | Null-safe strlen (character count) |
| 0x00475d03 | StrNEq | strncmp-like comparison, returns 1 if equal up to N chars |
| 0x00475dec | Hash_MixString | Hash mix over string bytes using MixKey |
| 0x00475e15 | Hash_MixStringUpper | Hash mix with Char_ToUpper on first string |
| 0x00475e60 | NoiseTable_Init | Seed RNG and fill 0x200-entry noise table at 0x5341f0 |
| 0x00475e87 | StrLen_Delay8 | strlen with 8-iteration delay loop (anti-analysis?) |
| 0x00475ed4 | LicenseKey_ComputeHash | Compute license hash from strings then mask + add constant |
| 0x00475ef9 | LicenseKey_EncryptBlock | 100-element permutation cipher with key expansion |
| 0x00477010 | CollisionObj_Init | Init collision object with vtable PTR_LAB_004da65c |
| 0x00477060 | Ray_SphereIntersect | Ray-sphere intersection test, returns hit T or flags |
| 0x00477120 | Vec3_ProjectOntoRay | Project point onto ray (origin + dir), result = dir*t + origin |
| 0x004771d0 | Ray_PlaneIntersectT | Ray-plane intersection, returns T or flags |
| 0x00477240 | Vec3_DotDiff | dot(a,n) - dot(b,n) — difference of dot products |
| 0x00477280 | Vec3_DotDiffAbs | abs(dot(a,n) - dot(b,n)) — absolute dot difference |
| 0x004772c0 | Vec3_Reflect | Reflect vector v across plane normal n: result = v - 2*dot(v,n)*n |
| 0x00477330 | AABB_FromSphere | Compute AABB min/max from sphere center, velocity, radius |
| 0x004774b0 | Tri_TestPointInside | Test if point is inside triangle using cross product edge tests |
| 0x00477670 | Segment_ClosestPoint | Find closest point on line segment to a point |
| 0x004777c0 | Vec3_ProjectOntoPlane | Project vector onto plane: result = v - (dot(v-origin,n))*n |
| 0x00477830 | MeshArchive_SetPosition | Set position and reset pool via vtable |
| 0x00477970 | MeshArchive_ReadChunks | Read 0x400-byte chunks from stream |
| 0x00477ac0 | MeshArchive_BuildTree | Recursive binary search tree for mesh data |
| 0x00477d60 | MeshArchive_LoadSubmesh | Load submesh with bitstream and D3DX retry loop |
| 0x00477f10 | MeshArchive_LoadAll | Load all meshes from archive with position tracking |
| 0x00478320 | MeshArchive_ReleaseBuffers | Release sub-buffers, set state=2 |
| 0x00478340 | MeshArchive_LoadFrame | Frame-level load with bitstream, mesh groups, position tracking |
| 0x00478680 | MeshArchive_dtor | Destructor: free all sub-resources, D3D resources, mesh groups |
| 0x00478800 | MeshArchive_SeekRead | Seek to position and read mesh data with bitstream |
Session 26 (cont2) — 10 renames — 69.3% documented
| Address | Name | Description |
|---|---|---|
| 0x00478b90 | MeshArchive_GetSubmeshPtr | Get pointer to submesh at index (or first if -1) |
| 0x00478bd0 | MeshArchive_ReadVertices | Read vertex data from current frame, apply signed-compression decode |
| 0x00478d60 | MeshArchive_LoadComplete | Full load: reset position, read chunks, build tree, load all, seekread |
| 0x00478e70 | MeshArchive_ctor | Constructor: init vtable, pool, D3D resources, mesh groups, position tracking |
| 0x00478f80 | MeshArchive_InitAndLoad | Create archive and fully load mesh data from stream |
| 0x00479050 | Reg_ReadD3DValue | Read DWORD value from HKLM/software\Microsoft\rd3D registry |
| 0x004793e0 | SceneList_Ctor | Init SceneList with vtable PTR_FUN_004da6b4, empty AthenaList |
| 0x00479400 | SceneList_Reset | Reset SceneList (vtable + Vec3List_Free) |
| 0x00479460 | SceneList_DeletingDtor | Deleting dtor for SceneList (Reset then free if bit 0 set) |
| 0x004794d0 | SplashScreen_Ctor | Splash screen ctor: brand.png + raptisoftlogo.png sprites, showcardgothic16 font |
Session 28 — 21 renames — 70.1% documented
| Address | Name | Description |
|---|---|---|
| 0x00456cd0 | Ball_InitBattleMode | Initialize ball for battle mode: sets physics params (friction 0.18, bounciness 1.0, radius 400.0), speed |
| 0x00456f70 | Gadget_AddSpriteSlot | Add sprite slot to gadget (sprite list with texture index + dimensions) |
| 0x0045b104 | D3DThunk_AcquireThreadFocus | D3D thunk: DetectShaderProfile + acquire thread focus |
| 0x0045b25f | Matrix_BuildScaleTranslation | Build combined scale+translation matrix (16-float array, scale*1.0, translation, 0s) |
| 0x0045b74c | Matrix_BuildRotationAxisScaled | Build rotation matrix around axis with scale factor (cos/sin of angle*scale) |
| 0x0045ba24 | D3DThunk_Present | D3D thunk: DetectShaderProfile + Present dispatch |
| 0x0045bb6f | D3DThunk_SetClipPlane | D3D thunk: indirect SetClipPlane dispatch |
| 0x0045bd1d | D3DThunk_DrawIndexedPrimitiveUP | D3D thunk: DetectShaderProfile + DrawIndexedPrimitiveUP (6 params) |
| 0x0045bd47 | D3DX_SlerpQuad | D3DX spherical lerp for quaternions (4 floats, uses 1-t)*t weighting |
| 0x0045bd9e | D3DThunk_SetVertexShader | D3D thunk: DetectShaderProfile + SetVertexShader dispatch |
| 0x0045bdde | D3DX_SquadInterpolate | D3DX squad interpolation between quaternions with blend factor |
| 0x0045c273 | Matrix_TransformPoint2D | 2D point transform: mat*vec2 (reads mat[0..5], vec2, outputs vec2) |
| 0x0045c2f3 | Matrix_TransformVector2D | 2D vector transform: mat*vec2 without translation (reads mat[0..5], vec2) |
| 0x0045c3cf | Matrix_TransformPoint3D | 3D point transform: mat*vec3 with perspective divide (reads mat[0..15]) |
| 0x0045c7fa | Matrix_BuildFullTransform | Build full transform matrix with scale/translation offsets and optional negation |
| 0x00479630 | SceneObject_DeletingDtor | SceneObject deleting destructor: release 3 render callbacks then dtor |
| 0x004796b0 | SceneObject_FadeAlpha | SceneObject fade alpha state machine: states 0-3 cycle through fade timers and callbacks |
| 0x00479820 | SceneObject_ScalarDtor | SceneObject scalar destructor (free if bit 0 set) |
| 0x00479a30 | GetOSVersionString | Build OS version string: "Windows NT/9x X.X - Build YYYY" |
| 0x00479dc0 | BugTracker_SetUserAgent | Set bug tracker user agent string |
| 0x00479e20 | BugTracker_Dtor | BugTracker destructor: restore exception filter, delete GDI objects, call string dtor |
Session 28 (cont) — 16 renames — Vec3_Normalize, WebClient, Matrix, COM, DDSURFACEDESC — 70.5% documented
| Address | Name | Description |
|---|---|---|
| 0x0048c24c | Vec3_Normalize | Normalize 3D vector in-place (with fast inverse sqrt, handles zero) |
| 0x0048c316 | Matrix_Multiply4x4_InPlace | 4x4 matrix multiply allowing in-place (out = a*b, temp buf if overlap) |
| 0x0048c3f9 | Matrix_Multiply4x4 | 4x4 matrix multiply (out = a*b with temp buf for aliasing) |
| 0x0048c487 | Matrix_InitVTable | Initialize matrix math vtable (7 entries: identity, transpose, inverse, mul, mul2, adjoint, normalize) |
| 0x0048c560 | WebClient_WndProc | WebClient window proc: handles WM_TIMER and WM_ASYNC message for DNS/connect/send |
| 0x0048c7d0 | WebClient_Ctor | Construct WebClient object with vtable, 3 AthenaStrings, Winsock init, WebWindow class registration |
| 0x0048c940 | WebClient_Dtor | Destroy WebClient: release window, deregister Winsock, destroy 3 strings |
| 0x0048c9c0 | WebClient_FetchURL | Parse URL, build HTTP GET request, create window, DNS resolve, connect and send |
| 0x0048cc70 | WebClient_DeletingDtor | WebClient deleting dtor (dtor + free if bit 0 set) |
| 0x0048cc90 | WebClient_RecvBuf_Dtor | Release WebClient receive buffer (free allocated ptr) |
| 0x0048cd0c | WebClient_RecvBuf_DeletingDtor | Receive buffer deleting dtor |
| 0x0048cd28 | COM_QueryInterface | COM IUnknown QueryInterface: validate IID, set out pointer, call AddRef |
| 0x0048cd6f | COM_IUnknown_Init | Initialize COM IUnknown struct (vtable, refcount=1, ptrs=0) |
| 0x0048ce17 | WebClient_InitResponse | Zero-initialize response fields (7 dwords cleared) |
| 0x0048ce89 | TextureCache_DeletingDtor | TextureCache deleting dtor: Tree_FreeRecursive then free |
| 0x0048cea5 | DDSURFACEDESC_ParsePixelFormat | Parse DDSURFACEDESC pixel format (D3DFMT enum), allocate palette, decompress RLE/compressed surfaces |
Session 29 — 6 renames — 70.7% documented
| Address | Name | Description |
|---|---|---|
| 0x0048e59c | DDTexture_CreateMipChain | DirectDraw texture mipmap chain creation (checks DDSD sig, parses pixel fmt, allocates surface levels, handles DXT compression) |
| 0x00490400 | DDSurface_CopyRects | DirectDraw surface rectangle copy (validates size match, optionally copies palette, pixel copy via scanline loop) |
| 0x00490538 | DDSurface_Blt3PointFilter | DirectDraw surface BLT with 3-tap bilinear filter (for 1/2 downscaling) |
| 0x00490a2f | DDSurface_Blt4PointFilter | DirectDraw surface BLT with 4-tap filter dispatch (dispatches to sub-filters by texture format) |
| 0x00490e52 | DDSurface_Blt5PointFilter | DirectDraw surface BLT with 5-tap filter (for 1/4 downscaling, bilinear + detail preservation) |
| 0x004917ca | DDSurface_Blt3PointWBuffer | DirectDraw surface BLT with 3-tap filter + Z buffer (scans Z range, computes span endpoints, bilinear filter) |
Session 30 — Matrix, Vec2, IMDCT, PNG, Font renames (71.6%→71.9%)
| Address | Name | Description |
|---|---|---|
| 0x0049dd82 | Matrix_BuildFromQuatScaleTranslate | Builds 4x4 matrix from quaternion + scale + translation (7-param SSE2) |
| 0x0049ef1a | Matrix_BuildFromTanAngles | Builds rotation matrix from tangent of X/Y angles |
| 0x0049f5f3 | Matrix_BuildFromNormTanAngles | Normalizes angles then builds rotation matrix from tan |
| 0x004a0860 | Matrix_BuildFromQuaternion3x3 | Builds 3x3 rotation matrix from quaternion with optional determinant |
| 0x004a1362 | Vec2_NormalizeSSE2 | Normalizes 2D vector using SSE2 reciprocal square root |
| 0x004a15f0 | Vec2_TransformMatrixPerspDivide | Transforms vec2 by 2x4 matrix with perspective divide |
| 0x004a304d | Script_ParseComponentList | Bytecode parser: extracts component list with nibble-encoded data |
| 0x004a503c | Object_SetDirtyFlag | Sets bit 0 in flags word at offset +0x60 |
| 0x004a8026 | IMDCT_TransformBlock | IMDCT 16-point transform for audio decoder |
| 0x004a91d0 | Font_RenderChannels | Renders font glyph channels via Huffman decode and callback |
| 0x004aba27 | Mem_Copy128Blocks | Copies data in 128-byte blocks (param3 << 7 >> 2 dwords) |
| 0x004ae18e | PNG_ParsePLTE | PNG PLTE (palette) chunk parser with IHDR check and CRC |
| 0x004b0220 | IMDCT_ForwardTransform | Forward IMDCT/DCT transform with fixed-point cosine tables |
Session 34 — CPUID, D3DXSkinMesh, MeshStrip, StreamReader, CRT, AthenaList, D3DX texture (83.3%→84.8%)
| Address | Name | Description |
|---|---|---|
| 0x0045dd60 | CPUID_CheckMMX | Checks CPUID feature bit 23 (MMX support) |
| 0x0045dd6f | CPUID_IsMMXAvailable | Returns MMX availability flag from cached CPU features |
| 0x0045dd7e | CPUID_Check3DNow | Checks CPUID extended features for 3DNow support |
| 0x0045dda0 | CPUID_DetectFeatures | Runs CPUID leaf 1 and stores feature flags |
| 0x0045ddb0 | CPUID_GetProcessorFeatures | Returns cached processor feature flags |
| 0x0045ddc0 | CPUID_CheckProcessorFeature | Tests specific bit in processor feature flags |
| 0x0045e010 | CopyVec2 | Copies 2-component float vector |
| 0x0045e190 | D3DXSkinMesh_Ctor | Initializes D3DX skin mesh object with vtable and defaults |
| 0x0045e340 | D3DXSkinMesh_InitTimerDefaults | Initializes timer-related defaults for skin mesh |
| 0x0045e6a0 | D3DXSkinMesh_CreateStrip | Creates strip from skin mesh data |
| 0x0045e770 | D3DXSkinMesh_CopyStripData | Copies strip data between buffers |
| 0x0045ea30 | D3DXSkinMesh_RebuildList | Rebuilds internal list structure for skin mesh |
| 0x0045eb40 | D3DXSkinMesh_DestroyAllObjects | Destroys all managed objects in skin mesh |
| 0x0045ec80 | D3DXSkinMesh_GenerateStrips | Generates triangle strips from mesh data |
| 0x0045ed90 | D3DXSkinMesh_DeletingDtor | Deleting destructor for D3DX skin mesh |
| 0x0045ee70 | MeshStrip_ComputeWinding | Computes winding order for mesh strip |
| 0x0045ef00 | MeshStrip_GenerateNextStrip | Generates next strip in sequence |
| 0x0045f0a0 | StreamReader_InitVtbl | Initializes stream reader vtable |
| 0x0045f2b0 | StreamReader_ReadLine | Reads a line from stream |
| 0x0045f360 | StreamReader_OpenFile | Opens file for stream reading |
| 0x0045f490 | StreamReader_DeletingDtor_Close | Deleting destructor that also closes file handle |
| 0x0045f5e0 | Mesh_ConnectFaceEdges | Connects face edges in mesh topology |
| 0x0045f680 | Mesh_AddFace | Adds face to mesh |
| 0x0045f8e0 | Mesh_SwapFaceEntries | Swaps two face entries in mesh |
| 0x0045f990 | Mesh_RemapFaceIndices | Remaps face indices according to remap table |
| 0x0045fa90 | Mesh_ReorderFacesByAdjacency | Reorders faces by adjacency for cache coherence |
| 0x0045fb50 | D3DX_CreateErrorHandler | Creates D3D error handler |
| 0x0045fd60 | VertexDecl_ParseDeclarationType | Parses vertex declaration type from D3D declaration |
| 0x0045ff50 | VertexDecl_CopyToBuffer | Copies vertex declaration data to buffer |
| 0x00460020 | VertexDecl_WriteBlendWeights | Writes blend weight data to vertex buffer |
| 0x0047ee44 | SmallIntArray_Init | Initializes small integer array |
| 0x0047ee8b | SmallIntArray_Find | Finds value in small integer array |
| 0x0047eeb2 | SmallIntArray_Push | Pushes value onto small integer array |
| 0x0047eed6 | ShortArray_Init | Initializes short array |
| 0x0047ef46 | ShortArray_Push | Pushes value onto short array |
| 0x0047efd7 | ShortArray_GetNextEdge | Gets next edge from short array iterator |
| 0x0047ef6c | Mesh_GetNextEdge | Gets next edge in mesh edge traversal |
| 0x0047efb7 | Mesh_GetPrevVertex | Gets previous vertex in mesh vertex list |
| 0x0047f091 | Mesh_LinkVertexToHead | Links vertex to head of vertex list |
| 0x0047f0d0 | Mesh_DecrementVertex | Decrements vertex reference count |
| 0x0047f0fa | Mesh_UnlinkShortVertex | Unlinks short vertex from list |
| 0x0047f1ca | Mesh_DecrementShortVertex | Decrements short vertex reference count |
| 0x0047f1fb | D3D_InitDisplayModes | Initializes available D3D display modes |
| 0x0047f5a4 | VertexDecl_ParseFVF | Parses FVF (Flexible Vertex Format) flags |
| 0x00481366 | MeshIter_InitEdge | Initializes mesh edge iterator |
| 0x004813c4 | MeshIter_InitShortEdge | Initializes mesh short edge iterator |
| 0x004819ec | MeshData_AttributeSort | Sorts mesh data by attribute group |
| 0x00481dbd | MeshData_SplitVerticesByAttribute | Splits vertices sharing attributes into separate copies |
| 0x00482611 | MeshData_SplitShortVertsByAttr | Splits short vertices by attribute group |
| 0x00482881 | MeshData_OptimizeVertexOrder | Optimizes vertex order for cache performance |
| 0x004829c6 | MeshData_OptimizeFaceOrder | Optimizes face order for vertex cache coherence |
| 0x00482ab0 | MeshData_FreeAdjBuffer | Frees adjacency buffer |
| 0x00482aba | MeshData_FindOrAddAttr | Finds or adds attribute entry |
| 0x00482adf | MeshData_FindOrAddShortAttr | Finds or adds short attribute entry |
| 0x00482b04 | MeshData_InitVertexAdj | Initializes vertex adjacency data |
| 0x00482bf0 | MeshData_RemoveFace | Removes face from mesh data |
| 0x00482c55 | MeshData_FindBestNextEdge | Finds best next edge for strip generation |
| 0x00482d2d | MeshData_InitShortVertexAdj | Initializes short vertex adjacency data |
| 0x00482e29 | MeshData_RemoveShortFace | Removes short face from mesh data |
| 0x00482ea4 | MeshData_FindBestNextShortEdge | Finds best next short edge for strip generation |
| 0x00486e92 | D3DXMesh_DrawSubset | Draws a mesh subset by attribute group |
| 0x00486f10 | CRT_InitLocaleVtable | Initializes C runtime locale vtable |
| 0x00486f80 | CRT_FormatInteger | Formats integer for printf-style output |
| 0x00487070 | CRT_FormatFloatOutput | Formats floating-point output with precision |
| 0x00487410 | CRT_NormalizeFP80 | Normalizes 80-bit extended precision float |
| 0x00487520 | CRT_ClassifyFP | Classifies floating-point value (NaN/Inf/Zero/Normal) |
| 0x004875c0 | CRT_FormatFloat | Formats float/double per printf format spec |
| 0x00487870 | CRT_FormatSpecifier | Parses and applies printf format specifier |
| 0x00487b30 | CRT_ParseFormatString | Parses printf format string |
| 0x00487e5e | CRT_InitCriticalSection | Initializes critical section for thread safety |
| 0x00487e70 | D3DXMesh_OptimizeInPlace | Optimizes mesh in place with flags |
| 0x00489280 | AthenaList_FreeAllChunks | Frees all chunks in Athena list buffer |
| 0x00489540 | AthenaList_SplitChunk | Splits a chunk in Athena list buffer |
| 0x004897a0 | AthenaList_WriteDword | Writes DWORD to Athena list buffer |
| 0x004897f0 | AthenaList_ReadByte | Reads byte from Athena list buffer |
| 0x00489830 | AthenaList_ReadDword | Reads DWORD from Athena list buffer |
| 0x00489b70 | CRT_FltControl87 | Wraps _control87 to set x87 FPU control word |
| 0x00490054 | D3DX_MipMap_16bit_555 | Generates mipmap for 16-bit 555 format surface |
| 0x00490175 | D3DX_MipMap_16bit_565 | Generates mipmap for 16-bit 565 format surface |
| 0x004902c1 | D3DX_MipMap_8bit | Generates mipmap for 8-bit palettized surface |
| 0x00490629 | D3DX_CopyRects_Point | Copies rects between surfaces with point (nearest) sampling |
| 0x0049082b | D3DX_CopyRects_Stretch | Copies rects between surfaces with stretch blt |
| 0x004913e4 | D3DX_TransformTex_Bilinear | Bilinear texture transform for D3DX |
Session 35 — D3DXMesh pipeline, D3DTexture, MeshData, Sort (84.8%→85.4%)
| Address | Name | Description |
|---|---|---|
| 0x00482f9c | D3DResource_ReleaseA | COM Release - decrements refcount, calls D3DDevice_ReleaseResourcesA when hits 0 |
| 0x00482fd4 | D3DResource_ReleaseB | COM Release variant B - same pattern, different resource cleanup |
| 0x0048316c | D3DXMesh_GenerateAdjacency | Generates face adjacency for 32-bit index mesh (locks IB, iterates with MeshIter_InitEdge) |
| 0x004833df | D3DXMesh_WeldVertices | Welds vertices by position hash + distance check (32-bit indices, heapsort) |
| 0x00483856 | Sort_IndexHeapByFloat | Heapsort of index array keyed by float values |
| 0x00483981 | Mesh_FindWeldVertex | Hash-lookup for vertex welding (xyz hash, adjacency chain check, 32-bit indices) |
| 0x00483ba4 | D3DTexture_CreateFromDesc | Creates D3D texture from description (pool/usage/size, handles Init/Locked paths) |
| 0x00483d6d | D3DTexture_CopyLockedData | Copies mesh data into locked texture (16-bit indices, 32→16-bit conversion) |
| 0x00483f2a | D3DTexture_CopyIndexData | Copies mesh index data into texture (32-bit to 16-bit index conversion) |
| 0x004840f9 | D3DXMesh_GenerateAdjacency16 | 16-bit index version of GenerateAdjacency (MeshIter_InitShortEdge) |
| 0x0048436d | D3DXMesh_WeldVertices16 | 16-bit index version of WeldVertices (uses Sort_IndexHeapByFloat) |
| 0x004847eb | Mesh_FindWeldVertex16 | 16-bit vertex weld hash lookup (ushort adjacency chain checking) |
| 0x004848b7 | D3DTexture_CopyLockedData16 | 16-bit locked data copy (32→16-bit index truncation) |
| 0x00484a87 | D3DTexture_CopyIndexData16 | 16-bit index data copy (D3DTexture_ResizeAndValidate path) |
| 0x00484c3a | D3DTexture_CopySurfaceData | Copies surface data between D3D textures (vertex format conversion) |
| 0x00484d0f | D3DXMesh_Stripify | Converts mesh to triangle strips (32-bit indices, uses MeshData_RemoveFace) |
| 0x00484de5 | D3DXMesh_StripifyOptimized | Optimized stripification with vcache-awareness (32-bit, SmallIntArray) |
| 0x004850e2 | D3DXMesh_Stripify16 | 16-bit strip conversion using short vertex adjacency |
| 0x004851d8 | D3DXMesh_StripifyOptimized16 | 16-bit optimized stripification with ShortArray vcache |
| 0x00485525 | D3DTexture_CreateSimple | Simplified texture creation from dimensions/usage/pool flags |
| 0x00485610 | D3DTexture_CloneFromDesc16 | Clones texture via 16-bit copy paths (CopyIndexData16/LockedData16/SurfaceData) |
| 0x004857d9 | D3DXMesh_ConvertAdjacencyToStrip | Builds adjacency and converts to strips (32-bit, MeshData_Init) |
| 0x00485ad9 | D3DXMesh_ConvertAdjacencyToStrip16 | 16-bit adjacency-to-strip conversion (MeshSubMesh_Init) |
| 0x00485dd7 | D3DXMesh_OptimizeFull | Full mesh optimization pipeline (adjacency, attr sort, vcache, strip, reorder) |
CRT Exception Handling (Session 46)
| Address | Name | Description |
|---|---|---|
| 0x004ba59f | Exception_ScalarDeletingDtor | Exception scalar deleting destructor |
| 0x004ba5bb | Exception_VbaseDtor | Exception vbase destructor |
| 0x004ba5c6 | CRT_ThrowInvalidStringPosition | Throws "invalid string position" |
| 0x004ba65e | BadAlloc_VbaseDtor | bad_alloc vbase destructor |
| 0x004ba669 | BadAlloc_ScalarDeletingDtor | bad_alloc scalar deleting destructor |
| 0x004ba69d | CRT_ThrowBadAlloc | Throws "bad allocation" |
| 0x004ba6fe | CRT_InitFPFuncTable | Initializes CRT floating-point function pointers |
| 0x004ba736 | CRT_InitFPState | Initializes FP state (FDIV check) |
| 0x004ba8ac | EH_FrameHandler | C++ exception frame handler |
| 0x004ba997 | CRT_PurecallPush | Push handler onto _purecall list |
| 0x004ba9bf | CRT_PurecallCheck | Check if handler in _purecall list |
| 0x004baa2c | EH_CallSettingFrame | Call __CallSettingFrame wrapper |
| 0x004baa7d | EH_NotifyCB | C++ EH notification callback |
| 0x004bac02 | CRT_SaveSEHContext | Save SEH context (EAX/EBP/ret) |
| 0x004793d8 | DllEntryPoint | DLL entry point |
CRT Standard Library (Session 46)
| Address | Name | Description |
|---|---|---|
| 0x004baf41 | CRT_ArrayUnwind1 | Array unwind helper (4 args) |
| 0x004bafa3 | CRT_ArrayUnwind2 | Array unwind helper (alt params) |
| 0x004bb411 | CRT_LeaveCritSec8 | LeaveCriticalSection(8) |
| 0x004bb429 | CRT_mkdir | CRT mkdir wrapper |
| 0x004bb455 | CRT_remove | CRT remove/delete file wrapper |
| 0x004bb6ed | CRT_LeaveCritSec4a | LeaveCriticalSection(4) variant |
| 0x004bb77d | CRT_LeaveCritSec4b | LeaveCriticalSection(4) variant |
| 0x004bb92c | CRT_LeaveCritSec4c | LeaveCriticalSection(4) variant |
| 0x004bb9a1 | CRT_Lock8 | __lock(8) wrapper |
| 0x004bb9aa | CRT_LeaveCritSec8b | LeaveCriticalSection(8) variant |
| 0x004bb9b3 | CRT_Initterm | Run init/term function arrays |
| 0x004bba18 | CRT_ExitProcess | CRT ExitProcess with cleanup |
| 0x004bbb0d | CRT_ExitProcessNoCleanup | ExitProcess without full cleanup |
| 0x004bbbc0 | CRT_UnlockFile | __unlock_file wrapper |
| 0x004bbc1a | CRT_UnlockFileEbp | __unlock_file via EBP |
| 0x004bbc24 | CRT_fsopen | __fsopen wrapper |
| 0x004bbc40 | FPU_AsinWrapper | FPU asin() wrapper |
| 0x004bbc5d | FPU_AsinHelper | FPU asin error handling |
| 0x004bbd0b | FPU_IsFinite | Check if double is finite |
| 0x004bbd20 | FPU_ClassifyDouble | Classify double (FP_CLASS) |
| 0x004bbe60 | FPU_RoundDouble | CRT round double with error handling |
| 0x004bbf81 | StdException_Ctor | std::exception constructor |
| 0x004bc058 | TypeInfo_Dtor | type_info destructor |
| 0x004bc081 | TypeInfo_ScalarDeletingDtor | type_info scalar deleting destructor |
| 0x004bc339 | CRT_UnlockFileEbp14 | __unlock_file via EBP+0x14 |
| 0x004bc768 | CRT_vsprintf | CRT vsprintf implementation |
| 0x004bc9c8 | CRT_UnlockFileEbp8 | __unlock_file via EBP+8 |
| 0x004bcabe | CRT_LeaveCritSec4d | LeaveCriticalSection(4) variant |
| 0x004bcc68 | CRT_UnlockFileEbp8b | __unlock_file via EBP+8 variant |
| 0x004bcd9d | CRT_UnlockFileEbp14b | __unlock_file via EBP+0x14 variant |
| 0x004bce60 | CRT_qsort | CRT qsort implementation |
| 0x004bd37d | CRT_DivCeil | Ceiling division helper |
| 0x004bd397 | CRT_GetLocalePtr | Get locale pointer |
| 0x004bd39d | CRT_FreeLocale | Free locale struct |
| 0x004bd467 | CRT_SetupThreadLocale | Setup thread locale |
| 0x004bd55b | CRT_LeaveCritSec12 | LeaveCriticalSection(12) |
| 0x004bd760 | FPU_FdivThunk | FPU fdiv thunk (SSE/fallback) |
| 0x004bd7bd | FPU_FdivHelper | FPU fdiv software emulation |
| 0x004bd982 | FPU_RoundCheck | FPU round check (no-op if already round) |
| 0x004bdf67 | CRT_cvtToScientific | CRT double-to-scientific conversion |
| 0x004bdfdb | CRT_FormatFractional | Format fractional part of float |
| 0x004be077 | CRT_cftof | Float format (%f) |
| 0x004be0df | CRT_cftoe | Float format (%e scientific) |
| 0x004be1d0 | CRT_SetFPUAffinity | Set FPU affinity mask |
| 0x004be36b | CRT_ReleaseTranslator | Release SEH translator count |
| 0x004be3cb | CRT_NLSStrLen | NLS string length |
| 0x004be3e8 | EH_CallCatchBlock | EH catch block caller |
| 0x004be52b | EH_UnwindAndRestore | Unwind and restore EH state |
| 0x004be772 | EH_SearchEnclosingCatch | Search for enclosing catch handler |
| 0x004be832 | EH_SearchExceptionHandler | Full EH exception handler search |
| 0x004beb18 | CRT_MTDeleteLocks | Free TLS + delete MT locks |
| 0x004beba7 | CRT_MTInitTLS | Init TLS slot for multithreading |
| 0x004bec6c | CRT_StrtodScan | strtod scanning/parser |
| 0x004bed07 | CRT_GetCharType | Get character type info (NLS) |
| 0x004bef25 | CRT_WriteFormatted | Full printf-style formatted output |
| 0x004bf708 | CRT_CheckThrowInfo | Validate throw info, call SEH handler |
| 0x004bf761 | CRT_SetUnhandledExceptionFilter | SetUnhandledExceptionFilter wrapper |
| 0x004bf7db | CRT_LeaveCritSec4e | LeaveCriticalSection(4) variant |
| 0x004bf7e4 | CRT_RuntimeError | Runtime error message box |
| 0x004bfaf8 | CRT_ScanMBCSWhitespace | Skip whitespace in MBCS string |
| 0x004bfc28 | CRT_ParseCommandLine | Parse command line into argv |
| 0x004bfd94 | CRT_GetMainArgs | __getmainargs implementation |
| 0x004bff58 | CRT_IOInit | CRT I/O initialization (stdin/stdout/stderr) |
| 0x004c0fc9 | CRT_UnlockFileHandle | __unlock_fhandle wrapper |
Session 2690 — 10 New Function Decompilations
| Address | Name | Description |
|---|---|---|
| 0x00458220 | AABB_TriangleIntersect | Test if triangle edge (2 vertices + FPU 3rd) overlaps AABB. Called 6× by AABB_TriangleTest6Edges for SAT collision test. Returns 1 if intersection, 0 otherwise. |
| 0x00465EF0 | Collision_TraverseSpatialTree | Recursive octree traversal for broad-phase collision. Walks spatial tree nodes, at leaf level tests each face vertex against query AABB via AABB_ContainsPoint. Matches appended to output AthenaList. Called from Stands_BuildCollision + self-recursive. |
| 0x00498200 | BitStream_ReadBits | Bit-level reader for Ogg Vorbis audio streams. Reads N bits (1-32) from byte buffer at arbitrary bit alignment, handles multi-byte crossing. 20+ xrefs from Vorbis header parsing (ID/Comment/Setup). Part of statically-linked Vorbis decoder. |
| 0x0049675E | D3DXMesh_ComputeLightingFromNormals | D3DX8 software mesh lighting: computes per-vertex 565-format colors from vertex normals × fixed light direction matrix. Clamps to [0,255], packs into 16-bit color stream. Called from D3DXMesh_ComputeLighting565 and ComputeLightingThenAssemble. |
| 0x00425F90 | App_CompleteRace | Finalizes a completed race: increments counter (+0x7C8), calls graphics vtable+0xFC twice to re-enable ZENABLE and ZWRITEENABLE render states, clears pending flag (+0x704=0). Called from 7 render contexts. |
| 0x00420DA0 | Board_Master_Update | Custom Update for Master Race (L14) ONLY — all other levels use default Scene_Update (0x419C00). Handles: Scene_Update + vtable calls, random sparkle particle spawn (1/11, NOT a nag screen — Ghidra mislabeled the particle ctor as "RegisterDialog_Render"), ball-vs-mechanical-object collision (distance check → velocity normalize + 3D sound + 3 trail particles), falling ball gravity + respawn. Confirmed unique via binary search: 0x420DA0 appears exactly once in the binary (vtable 0x4D12B4). |
| 0x00435B00 | CollisionLevel_PlayBreakSound | Plays 3D positional "break" sound at collision level position. Chains through parent→sound_mgr→sample. Sets 1.0f cooldown at +0x10E4. Called when breakable objects shatter. |
| 0x004B3878 | Audio_DecodeOutputBuffer | MO3 audio codec: decodes compressed frame to PCM output buffer. Manages stream state (source ptr, frame size, consumed count), calls codec vtable for decode, output converter for format transform, post-processor for remaining samples. Accumulates decoded count. |
| 0x0046C290 | App_OnMouseDown | Window message handler for mouse clicks. Calls SetCapture, sets button flags (L/R/M at +0x1C8/9/A), stores mouse X/Y, delegates to mouse interceptor if set (+0x1B0) or hit-tests UIWidget tree and dispatches click to widget vtable+0x14. |
| 0x004912E4 | ComputeScanlineZBuffer | D3DX8 software rasterizer: pre-computes per-pixel Z-buffer for bilinear texture filtering. Allocates width×16 bytes, computes V coordinate + fractional weight + adjacent texel V for each scanline pixel. Clamp/wrap mode based on param_1. Used by D3DX_TransformTex_Bilinear and DDSurface_Blt3PointWBuffer. |
🔗 Related Documents
Function Name Verification Rep
Function Name Verification Report
Total functions decompiled: 3,978
Functions analyzed for name accuracy: All 3,978
Confirmed misnomers: 7
Summary
All 3,305 previously-undecompiled functions have been decompiled from GhidraMCP and saved to analysis/ghidra/decompilations/batch_auto/. Each function was analyzed for name/behavior mismatches using the following methods:
- String literal analysis (what does the function actually reference?)
- Call graph analysis (what functions does it call?)
- Cross-reference verification (who calls it and how?)
- Constructor call counting (does 'Create' actually create multiple types?)
- Rendering call detection (is a 'Tick' actually doing rendering?)
Confirmed Misnomers
TimerDisplay (0x004298c0)
- Severity: CRITICAL
- Suggested name:
LoadingScreenGadget_Factory - Description: Named 'TimerDisplay' but is actually the LoadingScreenGadget factory function. Allocates 0x3628 bytes for a LoadingScreenGadget object and loads ALL game resources: 5 fonts (showcardgothic28/72/14/16, arialnarrow12bold), 40+ textures (hammy1-3.png, blueblot.png, goal.png, locktile.png, arrow1.png, etc.), 14 meshes, 7 levels, 55 sounds. The only timer-related string is 'timerblot.png'. This is the main game asset loading function, not a timer display.
- Evidence: operator_new(0x3628) + LoadingScreenGadget_Ctor + 199 string literals for fonts/textures/meshes
RegisterDialog_Render (0x00447920)
- Severity: MODERATE
- Suggested name:
PurchaseScreen_Render - Description: Named 'RegisterDialog_Render' suggesting a generic dialog, but this is specifically the game PURCHASE/REGISTRATION screen. Shows 'REGISTER HAMSTERBALL!', 'CLICK HERE TO BUY!', customer name/serial number input fields, and 'UNLOCK!' button. The name is technically correct (it renders a registration dialog) but 'PurchaseScreen_Render' would be more descriptive.
- Evidence: Strings: 'REGISTER HAMSTERBALL!', 'CLICK HERE TO BUY!', 'CUSTOMER NAME:', 'SERIAL NUMBER:', 'UNLOCK!'
DispatchCollisionEvents (0x0040c5d0)
- Severity: CRITICAL
- Suggested name:
LevelObjectCollisionHandler - Description: Named 'DispatchCollisionEvents' after just ONE of ~15 event types it handles. This is actually the GENERAL level object collision/event handler called from 28+ sites. Handles: N:SECRET, N:UNLOCKSECRET, E:NODIZZY, E:SAFESWITCH, E:LIMIT, E:BREAK, E:JUMP, E:ACTION, E:TRAJECTORY, N:NOCONTROL, N:WATER, N:TARPIT, DROPIN, PIPEBONK, POPOUT, N:GOAL, N:MOUSETRAP. Does NOT create anything - it's a collision response handler.
- Evidence: 28 xrefs (all UNCONDITIONAL_CALL), 0 ctor calls, 18 __stricmp calls dispatching events
RaceGoalReached_Tick (0x0044df70)
- Severity: MODERATE
- Suggested name:
RaceResultsScreen_Render - Description: Named 'Tick' but is primarily a RENDER function. Displays the race results screen with 'BEST RACE TIME:', 'WEASEL'S TIME:', 'BROKEN BALLS:', 'DIZZIED BALLS:', 'BRONZE TIME:', 'SILVER TIME:' and 'Click the mouse to continue!'. Has 56 Matrix_Scale4x4 calls and 14 UI_DrawTextCentered calls. Should be named RaceResultsScreen_Render.
- Evidence: 56 Matrix_Scale4x4 + 14 UI_DrawTextCentered + race result strings
CreateExpertLevelObjects (0x0040e250)
- Severity: MODERATE
- Suggested name:
CreateMechanicalObjects2 - Description: Named 'CreateExpertLevelObjects' but creates 6 different object types: BONK, SLOW/SUPER, UP, SAWBLADE, BRIDGE, and calls Bonk_ctor, TowerLevel_Ctor, Sawblade_Level_Ctor, Spinner_Level_ctor, Gear_Level_ctor, Tipper_Level_Ctor. Should be named after the category of objects it creates, not just one.
- Evidence: 6 distinct _ctor calls, handles BONK/SLOW/SUPER/UP/SAWBLADE/BRIDGE/NEG/JUDGE/BELL strings
CreateUpLevelObjects (0x004117b0)
- Severity: MODERATE
- Suggested name:
CreateRotatorsAndPendulums - Description: Named 'CreateUpLevelObjects' but creates Rotator and Pendulum objects. Handles LIFTER, SPEEDCYLINDER, TIMEBUTTON strings but the actual objects created are Rotator_ctor_sound, Rotator_ctor_nosound, and Pendulum_ctor.
- Evidence: 3 distinct _ctor calls: Rotator_ctor_sound, Rotator_ctor_nosound, Pendulum_ctor
HandleArenaCollisionEvents (0x00412850)
- Severity: MODERATE
- Suggested name:
CreateArenaObjects - Description: Named 'HandleArenaCollisionEvents' but handles N:SPINNER, N:BUMPER, E:LAUNCH, LAUNCHPOINT, EXPLODEHELPER, E:CALLHAMMER, E:HAMMERCHASE, E:CATAPULTBOTTOM. Only creates ArenaScoreParticle_ctor. This is an arena-specific object factory, not just a spinner creator.
- Evidence: Handles 8 different event/object types, only 1 ctor call (ArenaScoreParticle_ctor)
Notes
- Many 'Render' functions were flagged as suspicious for 'no draw calls', but this is a FALSE POSITIVE: D3D8 rendering uses vtable dispatches (
(**code**)(...)patterns) which don't show up as named function calls. These names are CORRECT. - CRT library functions (scanf, printf, pow, RaiseException) and codec functions (IDCT, IMDCT, Inflate, Vorbis) are correctly named — they're VS2003 CRT and bundled codec libraries.
- D3DX functions (CreateTextureFromFile, CreateMeshFromFormat, OptimizeMesh, WeldVertices, SkinMesh) are correctly named — they're the D3DX utility library bundled with the game.
- The
Create*function naming pattern is systematically misleading: each 'Create' function is actually a factory for MULTIPLE object types, named after just one. This appears to be how the original developers named them (after the first/primary object type in the switch statement).
🔗 Related Documents
Game Loop & Window Management
Game Loop & Window Management
Overview
The game loop (App_Run at 0x46BD80) implements a fixed-timestep update with
variable rendering. The WinMain (0x4278E0) sets up the window and enters
the main loop.
App_Run — Game Loop (0x46BD80)
void App_Run(App *app) {
Timer_Init(timer);
fps_target = app->fps_divisor; // +0x170 — frames per second divisor
frame_time = 0;
last_tick = 0;
last_render_tick = 0;
app->last_tick = GetTickCount();
while (!app->quit_flag) { // +0x159 = quit flag
frame_count = 0;
Sleep(0); // Yield to other processes
app->phase_name = "Background"; // +0x210
// Calculate milliseconds per frame
app->ms_per_frame = 1000 / app->fps_divisor; // +0x168
// FPS counter update (every 1 second)
now = GetTickCount();
if (fps_counter_target < now) {
if (app->show_fps) { // +0x1AC = show FPS flag
AthenaString_SprintfToBuffer(app->fps_string, format);
}
app->frame_counter = 0; // +0x194
fps_counter_target = now + 1000;
}
// Process Windows messages
while (PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE)) {
if (app->quit_flag) goto cleanup;
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
if (app->quit_flag) break;
// Fixed-timestep update
do {
now = GetTickCount();
// If we're ahead of schedule OR too many catch-up frames
if ((now - app->last_tick) < (app->ms_per_frame - 5) ||
catchup_frames > 9) {
app->phase_name = "Render";
catchup_frames = 0;
// Only render if enough time has passed
if ((1000 / fps_target) - 5 + last_render_tick < now) {
if (app->gfx != NULL &&
(!app->minimized || !app->active)) {
app->frame_counter++;
Graphics_BeginFrame(app->gfx, timer);
app->vtable[0x24](); // Pre-render
app->vtable[0x28](); // Render
app->vtable[0x2C](); // Post-render
Graphics_PresentOrEnd(app->gfx, TRUE);
}
last_render_tick = GetTickCount();
}
break;
}
// Logic update (fixed timestep)
app->update_count++; // +0x18C
app->phase_name = "Update";
Graphics_BeginFrame(app->gfx, timer);
app->vtable[0x20](); // Update (game logic)
// Advance tick counter
prev_tick = app->last_tick;
app->last_tick = prev_tick + app->ms_per_frame;
// Prevent tick drift (clamp to 1 second behind)
if (1000 < (GetTickCount() - (prev_tick + app->ms_per_frame))) {
app->last_tick = GetTickCount() - 1000;
}
frame_count++;
} while (frame_count < 1); // Max 1 update per frame
}
cleanup:
Timer_Cleanup(timer);
}
App Struct — Game Loop Offsets
| Offset | Type | Description |
|---|---|---|
| +0x159 | byte | Quit flag (non-zero = exit loop) |
| +0x15A | byte | Minimized flag |
| +0x16C | byte | Show FPS flag |
| +0x18C | int | Update count (total updates) |
| +0x194 | int | Frame counter (FPS display) |
| +0x164 | int | ms_per_frame (1000 / fps_divisor) |
| +0x168 | int | FPS divisor (target FPS) |
| +0x170 | int | fps_divisor (backup) |
| +0x1A0 | char* | FPS display string (+0x198?) |
| +0x1AC | byte | show_fps (1 = display FPS counter) |
| +0x210 | char* | Phase name ("Background"/"Update"/"Render") |
| +0x174 | GfxEngine* | Graphics engine pointer |
Vtable Dispatch Calls
The game uses vtable calls for the 4 main update phases:
| Vtable Offset | Call | Description |
|---|---|---|
| vtable[0x20] | Update | Game logic (physics, input, AI) |
| vtable[0x24] | Pre-render | Pre-render setup (camera, visibility) |
| vtable[0x28] | Render | Draw all scene objects |
| vtable[0x2C] | Post-render | UI overlays, HUD, menus |
WinMain (0x4278E0)
Entry point:
- Register window class (Athena class)
- Create window (1024x768 default, configurable)
- Initialize D3D8 device via Graphics_CreateDevice
- Call App_Initialize_Full (all subsystems)
- Enter App_Run game loop
- On exit: LoadOrSaveConfig (saves settings/shutdown)
Window Management
Window Procedure
Standard Windows message handler:
- WM_PAINT: Render invalidation
- WM_SIZE: Handle resize/minimize
- WM_ACTIVATE: Pause/unpause on focus change
- WM_CLOSE: Set quit flag
- WM_KEYDOWN/WM_KEYUP: Buffered input
Minimize Handling
When minimized (app->minimized ≠ 0):
- Game logic still updates (vtable[0x20])
- Rendering is skipped (only render when not minimized)
- Sleep(0) yields CPU during background
Timer System
Timer_Init / Timer_Cleanup
High-resolution timer using QueryPerformanceCounter:
- Timer_Init: Initialize performance counter
- Timer_Cleanup: Release timer resources
Frame Timing
- Target: 1000 / fps_divisor ms per frame (typically ~33ms = 30fps)
- Max catch-up frames: 9 (prevents spiral of death)
- Tick drift clamp: max 1 second behind real time
- Render throttled: only when 1000/fps - 5ms have elapsed
Graphics_BeginFrame (0x453B50)
Called before both update and render:
- Clear back buffer
- Begin D3D scene
- Set default render states
- Update timer
Graphics_PresentOrEnd (0x455A90)
Called after render:
- End D3D scene
- Present back buffer (swap chain)
- Reset render state tracker
Frame Budget
At 30 FPS:
- Update budget: ~33ms per logic tick
- Render budget: ~28ms (33 - 5ms margin)
- Catch-up limit: 9 frames before skipping
- Background: Sleep(0) between frames
Key App Offsets (Game State)
| Offset | Type | Description |
|---|---|---|
| +0x178 | Scene* | Current scene |
| +0x184 | Scene* | Loading screen scene |
| +0x1DC | SoundChannel* | Input event sound |
| +0x1E4 | InputDev* | Player 1 device |
| +0x1E8 | InputDev* | Player 2 device |
| +0x708 | int | Game state (3 = racing) |
| +0x174 | GfxEngine* | Graphics engine |
| +0x850 | bool | Mirror mode flag |
| +0x86C | uint8[0x50] | Best time data |
| +0x8BC | uint8[0x50] | Medal data |
🔗 Related Documents
Game Object Factory System
Hamsterball Game Object Factory System
Overview
The factory system creates all game objects from MESHWORLD level data. Each
level file contains named objects (N: and E: prefixed). The factories match
object name strings and instantiate the appropriate C++ object.
Factory Dispatch Chain
Level data → Named objects parsed from MESHWORLD
├─ CreateLevelObjects (0x4121D0) — main dispatcher
│ ├─ BRIDGE, TIPPER, BONK, BBRIDGE1/2, POPCYLINDER
│ ├─ BLOCKDAWG1/2, CATAPULT, GLUEBIE
│ └─ Falls through to CreatePlatformOrStands
├─ CreatePlatformOrStands (0x4133E0)
│ ├─ PLATFORM → Platform_ctor (0x371040, 0x10FC bytes)
│ └─ STANDS → Stands_ctor (0x462850, 0x10D0 bytes)
├─ CreateSpinny (0x4143D0) — ROTATOR → Rotator_ctor
├─ CreateLifter (0x414A20) — LIFTER → Lifter_ctor + falls through
├─ CreateWobbly1 (0x415460) — WOBBLY1-8 → GameLevel_ctor + falls through
├─ CreateMechanicalObjects (0x417FE0) — LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM
├─ CreateBumper (0x40FA20) — BUMPER1-4 → MeshWorld + 8x Scene_CollectByNameFilter
├─ CreateExpertLevelObjects (0x40E250) — Multi-factory for arenas
│ ├─ BONK → Bonk_ctor (0x1200 bytes)
│ ├─ "UP?" → TowerLevel_Ctor (0x1188 bytes)
│ ├─ SAWBLADE1/2 → Sawblade_Level_Ctor (0x111C bytes)
│ ├─ SPINNER/BRIDGE → Spinner_Level_ctor (0x10FC bytes)
│ ├─ JUDGE → Gear_Level_ctor (0x1100 bytes)
│ └─ BELL → Tipper_Level_Ctor (0x10E8 bytes)
├─ CreateBadBall (0x40BCA0) — BADBALL → Ball_ctor
├─ CreateSecretObjects (0x40BAA0) — SECRET / SECRETUNLOCK
├─ CreateMouseTrap (0x40BF50) — MOUSETRAP → MouseTrap_ctor
├─ NeonCollisionEvents (0x410D00) — E:LIMIT → Level collision
└─ CreateUpLevelObjects (0x4117B0) — SPEEDCYLINDER
CreateLevelObjects (0x4121D0) — Detailed
Main factory, called per named object in the level mesh. Uses __strnicmp for
prefix matching. All created objects are appended to Scene+0x2578.
Object Types
| Object Name | Match Length | Object Size | Constructor | Scene Storage |
|---|---|---|---|---|
| BRIDGE | 6 | — | Configures mesh | +0x436C (mesh), +0x4370 (collision) |
| TIPPER | 6 | 0x1104 | Tipper_ctor | +0x2578 |
| BONK | 4 | 0x1200 | Bonk_ctor | +0x2578, +0x540C |
| BBRIDGE1 | 8 | 0x1100 | BreakBridge_ctor | +0x2578, +0x5418 |
| BBRIDGE2 | 8 | 0x1100 | BreakBridge_ctor | +0x2578, +0x541C |
| POPCYLINDER | 11 | 0x10E8 | PopCylinder_ctor | +0x2578, +0x5428 |
| BLOCKDAWG1 | 10 | 0x1154 | Blockdawg_ctor | +0x2578 |
| BLOCKDAWG2 | 10 | 0x1154 | Blockdawg_ctor (flag+0x1152=1) | +0x2578 |
| CATAPULT | 8 | 0x1108 | Catapult_ctor | +0x2578, +0x584C |
| GLUEBIE | 7 | 0x110C | Gluebie_ctor | +0x2578, +0x6080 |
Gating
- TIPPER, BONK, BLOCKDAWG1/2, GLUEBIE are gated behind
App+0x23C != 0
(multiplayer/arena mode flag) - BRIDGE with "(NOCOLLIDE)" in name skips collision mesh setup
Object Position Source
All objects read position from param_4 (transform matrix):
param_4+0x04: X positionparam_4+0x08: Y positionparam_4+0x0C: Z positionparam_4+0x10: X rotationparam_4+0x14: Y rotation (also used for scale in some objects)param_4+0x18: Z rotation
CreateExpertLevelObjects (0x40E250) — Arena Multi-Factory
This is actually a combined factory that handles 6 different object types
for arena levels. All created objects get multiplayer gating (App+0x23C).
Object Types
| Object Name | Match | Object Size | Constructor | Scene Storage |
|---|---|---|---|---|
| BONK | 4 | 0x1200 | Bonk_ctor | +0x2578, +0x436C |
| "UP?" | 3 | 0x1188 | TowerLevel_Ctor | +0x2578 |
| SAWBLADE | 8 | 0x111C | Sawblade_Level_Ctor | +0x2578, +0x4370 or +0x4374 |
| SPINNER/BRIDGE | 6 | 0x10FC | Spinner_Level_ctor | +0x2578, +0x4380 or +0x4798 |
| JUDGE | 5 | 0x1100 | Gear_Level_ctor | +0x2578, +0x4BBC |
| BELL | 4 | 0x10E8 | Tipper_Level_Ctor | +0x2578, +0x4FD4 |
Special Flag Handling
- SLOW in name:
obj+0x43B = 1(TowerLevel slow mode) - SUPER in name:
obj+0x10ED = 1(TowerLevel super mode) - UP in name:
Sound_InitChannels(obj, 1)(TowerLevel with sound) - SAWBLADE1/2: stored at Scene+0x4370/0x4374,
Sawblade_SetBreakSound(n) - SPINNER1/2: stored in list at Scene+0x4380/0x4798
- NEG in SPINNER name:
obj+0x43E = -1.0(0xBF800000, reverse rotation) - JUDGE: added to judge list at Scene+0x4BBC
- BELL: stored as single reference at Scene+0x4FD4
CreateBumper (0x40FA20)
Creates a dedicated bumper level with 8 named bumper objects:
new MeshWorld(0x10D0, graphics, "levels\\level8")new CollisionLevel(0x10D0, meshWorld)— clone for collisionLevel_InitScene(this)- 8 iterations:
Scene_CollectByNameFilter("N:BUMPER%d", base+0x10E3) vtable[0x80]()— post-init callback
Stored: this+0x22B = MeshWorld, this+0x22C = CollisionLevel
Scene Object List Offsets
| Offset | Type | Description |
|---|---|---|
| +0x2578 | AthenaList | All active game objects |
| +0x436C | void* | Bonk/Hammer reference |
| +0x4370 | void* | Sawblade 1 |
| +0x4374 | void* | Sawblade 2 |
| +0x4378 | AthenaList | (level list 1) |
| +0x4380 | AthenaList | Spinner list 1 |
| +0x4790 | AthenaList | (level list 2) |
| +0x4798 | AthenaList | Spinner list 2 |
| +0x4BBC | AthenaList | Judge (Gear) list |
| +0x4FD4 | void* | Bell reference |
| +0x540C | void* | Bonk reference (TowerLevel) |
| +0x5410 | void* | BreakBridge mesh 1 |
| +0x5418 | void* | BreakBridge object 1 |
| +0x5414 | void* | BreakBridge mesh 2 |
| +0x541C | void* | BreakBridge object 2 |
| +0x5420 | void* | PopCylinder mesh |
| +0x5428 | AthenaList | PopCylinder list |
| +0x5840 | void* | Blockdawg1 mesh |
| +0x5844 | void* | Blockdawg2 mesh |
| +0x5848 | void* | Catapult mesh |
| +0x584C | AthenaList | Catapult list |
| +0x607C | void* | Gluebie mesh |
| +0x6080 | AthenaList | Gluebie list |
Scene Spawn Flow (0x41C5B0)
Scene_SpawnBallsAndObjects creates player balls and game objects for a race:
- Create GameObject (0xC60 bytes) for each ball
- Set start positions via "START%d-%d" naming pattern
- Scan "SAFESPOT"/"SAFEPOS" objects for safe landing zones
- Create BadBall/MouseTrap/SecretObjects/Flags/Signs
- Create dynamic objects (spinning platforms, traps)
CreateMechanicalObjects (0x417FE0)
| Object | Constructor | Size | Notes |
|---|---|---|---|
| LOOPER | Looper_ctor | 0x1500 | Looping animated platform |
| GEAR | Gear_ctor | 0x1514 | Spinning gear (with path) |
| BIGGEAR | Gear_ctor | 0x1514 | Larger gear variant |
| ROTATOR | Rotator_ctor | 0x1504 | Rotating platform |
| PENDULUM | Pendulum_ctor | 0x1504 | Swinging pendulum |
Board Construction Pattern
Every Board (race level) follows the same construction pattern in BoardLevelX_ctor:
Board_ctor(this, app)— base class, vtable 0x4D0260- Set level-specific vtable (e.g., 0x4D0890 for Dizzy)
- Init AthenaLists at +0x4378 and +0x4790
- Set title string and race name
- Load sub-levels as MeshWorld + CollisionLevel pairs
- Allocate sound channel
- Set physics: gravity vector, Matrix_Identity
LoadRaceData(this, "RACENAME")- Set display string
🔗 Related Documents
Game State & Race Lifecycle
Game State & Race Lifecycle
Overview
Hamsterball's game state is managed through App struct mode flags and a set of
state transition functions. The race lifecycle goes: Menu → Loading → Race → Result → Menu.
App State Machine
App Struct Mode Fields
| Offset | Type | Description |
|---|---|---|
| App+0x174 | GfxEngine* | Graphics engine (set cull mode on start) |
| App+0x178 | Scene* | Current scene |
| App+0x184 | Scene* | Loading screen scene |
| App+0x1DC | SoundChannel* | Sound channel for input events |
| App+0x1E4 | InputDev* | Player 1 input device |
| App+0x1E8 | InputDev* | Player 2 input device |
| App+0x21C | void* | State object (freed on start race) |
| App+0x220 | void* | Menu/dialog object (freed on start race) |
| App+0x224 | void* | Secondary dialog (freed on start race) |
| App+0x228 | void* | Tertiary dialog (freed on start race) |
| App+0x22C | LoadingScreenGadget* | Resource loader |
| App+0x534 | MusicPlayer* | Primary music channel |
| App+0x53C | MusicPlayer* | Secondary music channel |
| App+0x708 | int | Game state (3=racing) |
| App+0x7D2 | byte | Cull mode flag |
Game States
The game uses numeric state values stored at App+0x708:
- State 3: Active racing
- Other states managed by vtable dispatch from App_GameLoop (0x46BD80)
Race Start Sequence
App_StartRace (0x4287C0)
1. Scene_UpdateChildren(App+0x178) // Update all objects
2. Gfx_SetCullMode(App+0x174) // Set backface culling
3. Set D3D render state vtable[50] // 0x16 = 2 or 3 depending on flag
4. App+0x708 = 3 // Set game state to "racing"
5. Free old state objects at:
- App+0x21C (vtable[0x40]() destructor)
- App+0x228 (vtable[0x40]() destructor)
- App+0x224 (vtable[0x40]() destructor)
- App+0x220 (direct destructor call with param 1)
6. If music[0] exists: MusicPlayer_SetTempoScale(music[0], 1.0)
7. If music[1] exists: MusicPlayer_SetTempoScale(music[1], 0.5)
App_StartTournamentRace (0x4288B0)
Same as StartRace but with tournament-specific initialization.
App_Start2PRace (0x429230)
Two-player split-screen race initialization.
App_StartPracticeRace (0x428C50)
Practice mode - same track load but no tournament tracking.
App_StartMPRace (0x428B20)
Multiplayer race init - sets up arena mode.
Race End Sequence
App_CompleteRace (0x425F90)
When the ball crosses the finish line:
- Calculate elapsed time from timer
- Compare against best time (App+0x86C[player_index*4])
- If better: update best time and save to registry
- Determine medal: Bronze/Silver/Gold based on time thresholds
- Create RaceGoalReached object:
- Display "GOAL REACHED!" text (800ms timer)
- Show weasel rank sprite ("textures\ranks\weasel.png")
- Update best time blob at App+0x86C
- Progress tournament if applicable
Quit Race
QuitRace (0x42FAD0)
Exits current race back to menu:
- Set game state to menu mode
- Free scene objects
- Restore menu display
- Reset music tempo
QuitRaceMenu (0x42E6F0)
In-race quit confirmation menu.
Level Data Loading
LoadRaceData (0x40A120)
Loads MESHWORLD data for a specific level:
- Determine level path from race type
- MeshWorld_ctor to parse level geometry
- Level_InitScene to create objects from MESHWORLD data
- Scene_SpawnBallsAndObjects to place ball and level objects
- Level_SelectCameraProfile based on level type
Level-SikeType Race Handlers
Each level type has its own HandleRaceEnd callback:
| Address | Level | Handler |
|---|---|---|
| 0x420240 | Beginner | Board_Beginner_HandleRaceEnd |
| 0x420660 | Up | Board_Up_HandleRaceEnd |
| 0x222630 | Master | Board_Master_HandleRaceEnd |
These handle:
- Time comparison against stored best times
- Medal awards (Bronze/Silver/Gold thresholds)
- Tournament advancement
- Unlock conditions for mirror mode
Tournament Progression
Board_ctor (0x419030)
Initializes board/level with:
- LoadRaceData for the specific level
- Set camera profile
- Create HUD elements (timer, score display)
- Initialize physics parameters
Tournament_AdvanceRace (0x427080)
Switch statement handling race progression:
case 0: Warm Up → levels\arena-WarmUp
case 1: Beginner → levels\arena-Beginner
case 2: Intermediate→ levels\arena-Intermediate
case 3: Dizzy → levels\arena-dizzy
case 4: Expert → levels\arena-Expert
case 5: Neon → levels\arena-neon
case 6: Sky → levels\arena-Sky
case 7: Toob → levels\arena-Toob
case 8: Odd → levels\arena-Odd
case 9: Glass → levels\arena-Glass
case 10: Impossible → levels\arena-impossible
case 11: Master → levels\arena-Master
default: Mirror races (reverse direction)
Start Position Selection
Scene_SpawnBallsAndObjects (0x41C5B0)
For each player:
- Look up "START{player}-{player}" in hash table
- In tournament/mirror modes: random between START2-1 and START2-2
- If "START-DEBUG" exists: use that (cheat position)
- Create Ball struct (0xC60 bytes)
- Set defaults: radius=26, max_speed=5, gravity=0.5, speed_factor=1.05
- Set position from start point
- Append to ball list
Timer System
TimerArray (0x475980-0x475AA0)
Manages multiple countdown timers:
- TimerArray_Ctor: Initialize timer array
- TimerArray_RenderTimed: Render and tick timers
- Timer_Decrement: Advance timer by 1 frame
UITimer (0x448AC0)
On-screen timer display:
- Shows minutes:seconds:centiseconds
- Position derived from Scene+0x87C (camera)
ArenaBoard Timer (0x458E60-0x458E90)
Arena tournament timer:
- ToggleTimer_Init: Set countdown (typically 60-90 seconds)
- ToggleTimer_Tick: Per-frame countdown
- ToggleTimer_Cleanup: Reset timer
🔗 Related Documents
Game State Machine & Tournamen
Game State Machine & Tournament System
Architecture Overview
Hamsterball's game flow is a menu-driven state machine where each "screen" is a SceneObject added to the active scene. Menu transitions work by deleting the old menu object and creating a new one — there is no explicit state enum. Instead, game mode is tracked via boolean flags in the app object.
Menu Hierarchy (State Machine)
MainMenu (0x42DE50, vtable 0x4D3F30)
├── LET'S PLAY! → GameSelectionScreen (0x42E0C0, vtable 0x4D4010)
│ ├── TOURNAMENT → GameSelectionManager (0x433C00) "1PT"
│ │ ├── DifficultyMenu → Normal/Frenzied selection
│ │ ├── TourneyContinueDialog → Resume saved tournament
│ │ ├── Start → App_StartTourneyRace (0x428A40)
│ │ └── Result → TourneyMenu (0x450070, vtable 0x4D83F0)
│ ├── TIME TRIALS → TimeTrialMenu "1PTT"
│ ├── MIRROR TOURNAMENT → "1PMT" (if unlocked, else "LOCKED")
│ ├── PARTY GAMES → MPMenu (0x42E1A0) "PARTY"
│ └── PREVIOUS → back to MainMenu
├── HIGH SCORES → "HS"
├── OPTIONS → OptionsMenu "OP"
├── CREDITS → "CR"
├── REGISTER GAME → "RG" (if not registered)
├── MINI GAMES → "MG" (if unlocked)
└── EXIT TO DESKTOP → "EXIT"
Game Mode Flags (App Object)
| Offset | Type | Description |
|---|---|---|
| App+0x234 | bool | is_multiplayer (from menu selection) |
| App+0x235 | bool | is_tournament_mode |
| App+0x236 | bool | is_mirror_mode (mirror tournament) |
| App+0x237 | bool | (cleared on race start) |
| App+0x23C | bool | race_active (set to 1 when race starts) |
| App+0x5D4 | bool | is_single_player |
| App+0x5D5 | bool | (cleared on tourney entry) |
| App+0x5D7 | bool | (cleared on race start, set 1 during) |
| App+0x677 | bool | (set 1 = default, cleared if MP) |
| App+0x717 | bool | (set 1 on race start) |
| App+0x7B7 | bool | (set 1 on race start) |
| App+0x850 | bool | mirror_tournament_unlocked |
| App+0x851-0x865 | bool[15] | Per-race visited flags (sound triggers on first visit) |
Tournament System
Tournament Flow
- Main Menu → "LET'S PLAY!" → Game Menu (GameSelectionScreen)
- Game Menu → "TOURNAMENT" (button code "1PT") → GameSelectionManager
- GameSelectionManager checks for
DATA\TOURNAMENT.SAV - If save exists: TourneyContinueDialog — Continue or New?
- If no save or New: DifficultyMenu — Normal or Frenzied?
- Difficulty selected → App_StartTourneyRace (0x428A40)
- Race completes → TourneyMenu (0x450070) — shows results
- TourneyMenu → "PLAY!" → Tournament_AdvanceRace → next race
- After all 14 races: "TOURNAMENT WINNER!" or "LOST TOURNAMENT"
GameSelectionManager (0x433C00)
Central button handler for game mode selection. Receives button codes as strings:
| Button Code | Action | Target Menu |
|---|---|---|
| "BACK" | Return to MainMenu | MainMenu_ctor (0x42DE50) |
| "LOCKED" | Mirror tournament locked | OkayDialog with unlock message |
| "1PT" | 1P Tournament | DifficultyMenu or TourneyContinueDialog |
| "1PMT" | 1P Mirror Tournament | DifficultyMenu (mirror flag set) |
| "1PP" | Practice | PracticeMenu_ctor |
| "1PTT" | Time Trials | TimeTrialMenu_ctor |
| "PARTY" | Party/Multiplayer | MPMenu_ctor (0x42E1A0) |
For "1PT" and "1PMT", the manager:
- Sets
App+0x235 = 1(tournament flag) - Sets
App+0x236= 1 for mirror, 0 for normal - Clears
App+0x5D5, 0x5D7 - Sets
App+0x5D4 = 1(single player) - Checks for
DATA\TOURNAMENT.SAVfile - If save exists AND
Game_SetInProgress()returns true → TourneyContinueDialog - Else → DifficultyMenu (fresh start)
DifficultyMenu
Presented when starting a new tournament:
- NORMAL — Standard time pool
- FRENZIED — Reduced time pool ("Experts only! At Frenzied Difficulty, you get less time")
App_StartTourneyRace (0x428A40)
Entry point for tournament race initialization:
App_StartTourneyRace(app):
App_StartRace(app)
app.is_237 = 0
app.is_717 = 1
app.is_7b7 = 1
app.is_5d7 = 0
app.is_677 = 1
// Create PlayerProfile (0x98 bytes)
profile = PlayerProfile_ctor(app, app.is_234)
app.player_profile = profile // +0x220
// Load tournament save and show menu
TourneyMenu_LoadSaveAndShow(profile, "DATA\\TOURNAMENT.SAV")
// Set graphics cull mode based on mirror flag
if !app.is_mirror:
gfx.reversed = 0; cull = default
else:
gfx.reversed = 1; cull = mirror mode
// Clean up old UI objects
delete app.old_ui_0x90c
delete app.old_ui_0x910
App_StartMPRace (0x428B20)
Multiplayer race initialization:
App_StartMPRace(app, param_1):
App_StartRace(app)
app.is_mp = 1 (0x717, 0x7b7)
app.is_5d7 = 0
app.is_677 = 1 (cleared if MP flag set)
// Create PlayerProfile
profile = PlayerProfile_ctor(app, app.is_234)
app.player_profile = profile // +0x220
profile[2] = param_1
profile.is_active = 1 // +0x10
// Start tournament advancement
Tournament_AdvanceRace(profile, 0)
// Clean up old UI objects
delete app.old_ui_0x90c
delete app.old_ui_0x910
TourneyMenu (0x450070, vtable 0x4D83F0)
Post-race tournament screen showing:
- PLAY! — Advance to next race
- ROLLBACK — Re-do previous race (only if current_race > 1, shows "RXLL" if race <= 1)
- MAIN MENU — Exit tournament
If tournament is in progress (not warm-up race):
- Shows rank badge from
textures\ranks\%d.jpg - Rank computed by comparing accumulated time against 16 thresholds at
DAT_004F710C - Warm-up race text: "TAKE YOUR TIME ON THE WARM-UP RACE! This easy little race lets you get the hang of various hamsterball tricks without affecting your time pool."
Race visit tracking (per-race "first visit" sound triggers):
| Race # | App Flag Offset | Race |
|---|---|---|
| 3 | App+0x851 | Race 3 |
| 4 | App+0x852 | Race 4 |
| 5 | App+0x853 | Race 5 |
| 6 | App+0x863 | Race 6 |
| 7 | App+0x854 | Race 7 |
| 8 | App+0x855 | Race 8 |
| 9 | App+0x856 | Race 9 |
| 10 | App+0x857 | Race 10 |
| 11 | App+0x864 | Race 11 |
| 12 | App+0x858 | Race 12 |
| 13 | App+0x859 | Race 13 |
| 14 | App+0x865 | Race 14 |
On first visit to a race, this+0x1108 = 1 and a "first visit" sound plays.
PlayerProfile Structure (0x98 bytes)
Created during tournament/race init:
| Offset | Type | Description |
|---|---|---|
| +0x00 | vtable* | PlayerProfile vtable |
| +0x04 | App* | Back-pointer to App |
| +0x08 | int | Current race number (1-14) |
| +0x10 | bool | is_active (set 1 for MP) |
| +0x90 | int | Accumulated time for save |
| +0x95 | bool | has_rollback_available |
| +0x96 | bool | tournament_completed |
| +0x5E4 | float | Total accumulated time (for rank calculation) |
Tournament Save File
- Path:
DATA\TOURNAMENT.SAV - Written via
TourneyMenu_WriteSave(profile, path)after each race - Loaded via
TourneyMenu_LoadSaveAndShow(profile, path)at tournament start - Contains: current race number, accumulated times, visited flags
Unlock System
Race unlock: Each race is unlocked by reaching it during a Normal or Frenzied tournament game:
"THIS RACE ISN'T UNLOCKED YET! TO UNLOCK %s RACE, YOU NEED TO REACH IT
WHILE PLAYING A NORMAL OR FRENZIED TOURNAMENT GAME!"
Arena unlock: Each arena requires finding a secret unlock spot in a specific race:
"THIS ARENA ISN'T UNLOCKED YET! TO UNLOCK %s ARENA, YOU NEED TO FIND THE
SECRET UNLOCK SPOT IN THE %s RACE DURING A NORMAL OR FRENZIED TOURNAMENT GAME!"
Mirror Tournament unlock: Win a tournament at Normal or Frenzied difficulty:
"THE MIRROR TOURNAMENT ISN'T UNLOCKED YET! TO UNLOCK THE MIRROR TOURNAMENT,
YOU NEED TO WIN A TOURNAMENT AT NORMAL OR FRENZIED DIFFICULTY!"
- Tracked by
App+0x850(mirror_tournament_unlocked bool)
Game End States
- TOURNAMENT WINNER! — Won all 14 races within time pool
- LOST TOURNAMENT — Time pool depleted
- "YOU HAVE LOST THE TOURNAMENT. IN ORDER TO RESUME, YOU NEED TO SELECT 'ROLLBACK' ON THE MENU!"
- ROLLBACK option lets player redo the previous race with their remaining time
Key Address Map
| Address | Function | Description |
|---|---|---|
| 0x42DE50 | MainMenu_ctor | Main menu (PLAY/HS/OP/CR/RG/MG/EXIT) |
| 0x42E0C0 | GameSelectionScreen_ctor | Game menu (Tourney/TT/Mirror/Party/Back) |
| 0x433C00 | GameSelectionManager | Mode selection button handler |
| 0x428A40 | App_StartTourneyRace | Tournament race init + save load |
| 0x428B20 | App_StartMPRace | Multiplayer race init |
| 0x450070 | TourneyMenu_ctor | Post-race tournament results menu |
| 0x42E1A0 | MPMenu_ctor | Multiplayer menu |
Reimplementation Notes
State Machine (SDL2)
- Replace menu system with a simple state stack or scene graph
- Each "menu" is a screen class with Update/Render/HandleInput methods
- Button codes become enum values or string IDs
- Replace
operator_new(size) + ctor()with modern factory methods - The boolean-flag approach is fragile — consider an explicit
GameModeenum:enum class GameMode { MainMenu, Tournament, TimeTrial, MirrorTournament, Practice, PartyGames, Multiplayer };
Tournament Save
- Use JSON or simple binary format instead of raw struct dump
- PlayerProfile is only 0x98 bytes — small enough for JSON
- Rank thresholds at
DAT_004F710Cshould be extracted as constants
Mirror Mode
- Mirror flag at
App+0x236reverses cull mode (D3DRS_CULLMODE) - Equivalent to:
glFrontFace(GL_CW)vsglFrontFace(GL_CCW)in OpenGL - Also flips camera/track direction — need to verify if track geometry is mirrored or just rendering
🔗 Related Documents
Global Bonk (Hammer) Mod
Global Bonk (Hammer) Mod
Spawns Bonk the Hammer on any level. Hammers chase ALL ball entities, physically move toward their targets, and smash without head-turning during the smash animation.
Usage
- Load
GlobalBonk.CEAin Cheat Engine - Enable the script
- Set
SpawnBonkto 1 in CE (or use a hotkey) - Bonk spawns at player 1's position and activates when any ball comes within ~30 units
v2 Fixes (June 2026)
Issue 1: No head turn during smash
Root cause: The old script wrote player 1's position into the target fields (+0x1120-0x1128) every frame, even during smash state. This fought with the game's own target-finding logic and forced the hammer to always face player 1.
Fix: Removed ALL manual target writes. The game's vtable[11] (Bonk_Update at 0x43F930) sets targets itself. During state 2 (smash), positions are frozen — no rotation drift.
Issue 2: Hammer doesn't move (only rotates head)
Root cause: Two compounding bugs:
- (a)
vtable[11]was never called by the script — the old version only manually wrote positions, never invoking the game's update function. - (b) The original Bonk is a stationary turret —
currentmoves towardstart, butstartwas set to the spawn position, socurrentnever moved toward the ball.
Fix:
- Call
vtable[11]every frame (during player 1's Ball_Update for once-per-frame timing) - After the call, if NOT smashing (state ≠ 2), copy target → start (
0x1120→0x1108) - This redirects the game's own movement code:
currentnow moves toward the ball at 20%/frame
Issue 3: Only targets player 1
Root cause: The old script filtered everything to [esi+0x18]==0 (player 1 only) and overwrote the game's target with player 1's position. But vtable[11] already iterates the full ball list (board+0x29D4) and finds the nearest ball automatically.
Fix:
- Removed player-1 filter from proximity check — any ball can activate
- Do NOT override target positions — let
vtable[11]find the nearest ball from ALL balls - Activation state set to 0 (find target) so the game finds nearest ball on the very next frame
Object Details
- Name string: "BONK" (0x4CFA4C)
- Alloc size: 0x1200 (4608 bytes)
- Constructor: 0x438850 (ret 0x10 — 4 stack params)
- Vtable: 0x4D5120
- Mesh: "levels\level5-bonk" — loaded INTERNALLY by the constructor
- Update function: vtable[11] at offset 0x2C → 0x43F930
Key Object Fields
| Offset | Size | Description |
|---|---|---|
| +0x10D0 | 4 | Board pointer |
| +0x10D4 | 12 | Current position XYZ (float) |
| +0x10E0 | 12 | Home position XYZ (float) |
| +0x10F8 | 4 | CollisionLevel pointer |
| +0x10FC | 1 | Idle flag (1=idle, 0=active) |
| +0x10FD | 1 | Chasing flag (1=chasing) |
| +0x1100 | 4 | Timer/decay (init 1000, decays to 0) |
| +0x1104 | 4 | State machine: 0=find target, 1=approach, 2=smash |
| +0x1108 | 12 | Start position (where current moves toward) |
| +0x1120 | 12 | Target position (ball position, set by vtable[11]) |
| +0x1138 | 4 | Speed (starts 0.5, ×1.15/frame until ≥90 → smash) |
| +0x113C | 4 | Smash countdown (25 frames) |
State Machine (vtable[11] @ 0x43F930)
State 0 (find target): speed -= 10.0
When speed < 1.0:
speed = 0.5, state → 1
Iterate board+0x29D4 ball list, find NEAREST ball
Set target (0x1120-0x1128) = ball position + radius offset
Set start (0x1108-0x1110) = current position
State 1 (approach): speed *= 1.15
Move home → target at dist*0.333/frame
Move current → start at dist*0.2/frame
When speed ≥ 90.0: SMASH
Play BONKBASH sound, state → 2, countdown = 25
Knockback all balls within 80.0 units
State 2 (smash): countdown -= 1, when 0 → state 0
Rendering (every frame):
Gfx_RotateY(home.x - current.x, 0, home.z - current.z)
Gfx_SetPosition(current.x, current.y - timer, current.z)
How v2 Makes the Hammer Chase
The original game's Bonk is stationary — current moves toward start, but start = spawn position, so the hammer never physically relocates. v2 fixes this by redirecting start = target after each vtable[11] call (when not smashing). This makes the game's own movement code move current toward the target ball at 20% of remaining distance per frame (~0.25s to reach 95%).
No External Dependencies
- Mesh loaded internally by constructor (no Board+0x43xx needed)
- CollisionLevel created internally
- Only appends to Board+0x2578 (always init'd)
- Board+0x436C is saved/restored (slot JIT pattern)
Hook Point
- Address: 0x405E22 (inside Ball_Update, ESI = current ball)
- Original:
mov eax, [esi+0x0C5C](6 bytes: 8B 86 5C 0C 00 00) - Player 1 filter (
[esi+0x18]==0) used only for: vtable[11] call (once per frame), spawn trigger, position save - Proximity check: no player filter — any ball can activate idle bonks
🔗 Related Documents
Global Bumper & Impossible Obj
Global Bumper & Impossible Objects — Reverse Engineering Analysis
1. Bumpers in Beginner Race
What Are Bumpers?
Bumpers are bouncy collision objects embedded in MESHWORLD geometry. They are NOT standalone game objects with their own vtable — they are mesh triangles tagged with N:BUMPER%d event names in the MESHWORLD octree (Section 6).
Where Do They Come From?
- Beginner Race (
LevelCascade.MESHWORLD): ContainsN:BUMPER1throughN:BUMPER6ref points - Toob Race (
Level8.MESHWORLD): ContainsN:BUMPER1throughN:BUMPER8 - The bumper visual mesh comes from the level's own MESHWORLD file
- The bumper collision event is processed by the board's collision dispatch
How Are Bumpers Created?
Step 1: Board Initialization
LevelBoard_Beginner_ctor (0x004200E0)
→ Board_ctor(this, param_1)
→ Sets vtable = 0x4D1098 (Beginner board)
→ LoadRaceData(this, "CASCADERACE")
→ vtable[0x12] = Scene_SetupLevelCascade (0x004110D0)
Step 2: Scene Setup (vtable[0x12] call)
Scene_SetupLevelCascade (0x004110D0)
1. operator_new(0x10D0) → allocates MeshWorld
2. MeshWorld_ctor(mesh, App+0x174, "levels\levelcascade")
→ Parses LevelCascade.MESHWORLD binary
→ Builds octree with all geometry including N:BUMPER%d tagged triangles
3. Stores mesh at Board+0x8AC (param_1[0x22B])
4. operator_new(0x10D0) → allocates CollisionLevel
5. CollisionLevel_ctorWithLevel(colLevel, mesh)
→ Clones collision geometry from mesh
6. Stores at Board+0x8B0 (param_1[0x22C])
7. Level_InitScene(board)
→ Iterates Section 1 ref points
→ For each ref, dispatches to CreateLevelObjects/CreateMechanicalObjects/CreateSpeedCylinder
→ N:BUMPER%d refs do NOT match any factory → fall through to CreatePlatformOrStands
→ But the bumper TRIANGLES are already in the octree from MeshWorld_ctor
8. vtable[0x80]() — post-init callback
9. Loops 8 times (i=1..8):
- AthenaString_Format("N:BUMPER%d", i) → generates "N:BUMPER1", "N:BUMPER2", etc.
- Scene_CollectByNameFilter(mesh, nameFilter, &bumperSlot[i])
→ Searches the MeshWorld octree for triangles tagged with this event name
→ Stores collected triangle groups in Board+0x43E4 + i*0x418 (bumper slot array)
- Sets Board+0x642C + i*4 = 0 (bumper activation state)
Key insight: Bumpers are NOT created by a factory function. They are pre-baked into the level's MESHWORLD file as tagged collision triangles. The Scene_CollectByNameFilter call gathers them into slot arrays for the collision system.
Step 3: Collision Dispatch (vtable[0x1D])
When the ball collides with any triangle in the level:
Beginner Collision Dispatch (0x004111E0) — vtable[0x1D]
1. Checks if event name starts with "N:BUMPER"
2. If yes:
a. Sound_Play3D — plays bumper bounce sound at ball position
b. Reads ball velocity from Ball+0xCA4 (velX), +0xCA8 (velY), +0xCAC (velZ)
c. Scales velocity by _DAT_004CF41C (a float multiplier)
d. If speed < threshold: Vec3_NormalizeAndScale(&vel, 5.0) — small bounce
e. If speed > threshold: Vec3_NormalizeAndScale(&vel, 10.0) — big bounce
f. Writes scaled velocity back to Ball+0xCA4/+0xCA8/+0xCAC
g. Reads bumper index from event name: _atol(name + 8) → "N:BUMPER3" → index 3
h. Sets Board+0x6428 + index*4 = 1.0 (0x3F800000) — bumper activation flag
3. Falls through to DispatchCollisionEvents (0x0040C5D0) for shared event handling
Bumper Data Structure
| Offset | Type | Description |
|---|---|---|
| Board+0x43E4 + i*0x418 | struct | Bumper slot[i] (collected triangle groups) |
| Board+0x6428 + i*4 | float | Bumper activation state (0=inactive, 1=just hit) |
| Board+0x642C + i*4 | int | Bumper state flag |
Why Bumpers Are Hard to Clone Globally
Bumpers are embedded collision triangles, not standalone objects. To spawn them globally:
- You must load the Level8.MESHWORLD (or LevelCascade.MESHWORLD) file which contains the bumper geometry
- Create a Stands object from that mesh (clones the octree including collision triangles)
- Position it at the player
- Register it in the board's collision lists (Board+0x10EC and Board+0x8B0→+0x18)
The collision dispatch (vtable[0x1D]) automatically handles "N:BUMPER" events regardless of which board type you're on, because ALL boards inherit from the same base collision dispatch that calls DispatchCollisionEvents.
2. Impossible Race Objects
Overview
Impossible Race (LevelImpossible.MESHWORLD) contains 5 types of mechanical objects, each loaded from a separate sub-MESHWORLD file:
| Object | Mesh File | String Address | Board Slot | Alloc Size | Ctor Address | RET N |
|---|---|---|---|---|---|---|
| LOOPER | LevelImpossible-Looper | 0x4D2158 | +0x436C | 0x1500 | 0x435800 | 0x14 (5 params) |
| GEAR | LevelImpossible-Gear | 0x4D213C | +0x4370 | 0x1514 | 0x437590 | 0x20 (8 params) |
| BIGGEAR | LevelImpossible-BigGear | 0x4D211C | +0x4374 | 0x1514 | 0x437590 | 0x20 (8 params) |
| ROTATOR | LevelImpossible-Rotator | 0x4D20FC | +0x4378 | 0x1508 | 0x435940 | 0x14 (5 params) |
| PENDULUM | LevelImpossible-Pendulum | 0x4D20DC | +0x437C | 0x1504 | 0x436A20 | 0x18 (6 params) |
How They Are Created
Step 1: Board_Impossible_ctor (0x00424C20)
Board_ctor(this, param_1);
this->vtable = 0x4D21C0; // Impossible board
this->name = "Board (Impossible)";
this->race_name = "IMPOSSIBLE RACE";
LoadRaceData(this, "IMPOSSIBLERACE");
// Pre-load 5 sub-meshes into board slots:
this->mesh_Looper = MeshWorld_ctor(new(0x10D0), App+0x174, "Levels\LevelImpossible-Looper"); // +0x436C
this->mesh_Gear = MeshWorld_ctor(new(0x10D0), App+0x174, "Levels\LevelImpossible-Gear"); // +0x4370
this->mesh_BigGear = MeshWorld_ctor(new(0x10D0), App+0x174, "Levels\LevelImpossible-BigGear"); // +0x4374
this->mesh_Rotator = MeshWorld_ctor(new(0x10D0), App+0x174, "Levels\LevelImpossible-Rotator"); // +0x4378
this->mesh_Pendulum = MeshWorld_ctor(new(0x10D0), App+0x174, "Levels\LevelImpossible-Pendulum"); // +0x437C
Step 2: LevelImpossible_InitScene (0x00417F20) — vtable[0x12]
mesh = MeshWorld_ctor(new(0x10D0), App+0x174, "levels\levelimpossible");
board->sceneMesh = mesh; // +0x8AC
board->collisionMesh = CollisionLevel_ctorWithLevel(new(0x10D0), mesh); // +0x8B0
Level_InitScene(board); // Creates dynamic objects from MESHWORLD refs
board->vtable[0x80](); // Post-init
Step 3: Scene_CreateDynamicObjects (0x0040C430)
Iterates all Section 1 ref points in the MESHWORLD octree. For each ref, calls the board's vtable[0x84]:
vtable[0x84](refName, &outObj, &outCol, refParams)
This dispatches to:
- CreateLevelObjects (0x004121D0): BRIDGE, TIPPER, BONK, BBRIDGE1-2, POPCYLINDER, BLOCKDAWG1-2, CATAPULT, GLUEBIE
- CreateMechanicalObjects (0x00417FE0): LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM
- CreateSpeedCylinder (0x004117B0): LIFTER, SPEEDCYLINDER, TIMEBUTTON
- Scene_CreateObject_Gear (0x00418760): GEAR (arena variant)
- CreatePlatformOrStands (0x004133E0): Fallback for anything else
Step 4: CreateMechanicalObjects (0x00417FE0) — Detailed
For each ref name, uses __strnicmp to match:
LOOPER (strnicmp "LOOPER", 6):
obj = operator_new(0x1500);
Looper_ctor(obj, board, posX, posY, posZ, board->mesh_Looper); // +0x436C
collisionLevel = obj->CollisionLevel; // +0x10D4
AthenaList_Append(board+0x2578, obj); // general objects
GEAR (strnicmp "GEAR", 4):
obj = operator_new(0x1514);
Gear_ctor(obj, board, posX, posY, posZ, dirX, dirY, dirZ, board->mesh_Gear); // +0x4370
AthenaList_Append(board+0x2578, obj);
BIGGEAR (strnicmp "BIGGEAR", 7):
obj = operator_new(0x1514);
Gear_ctor(obj, board, posX, posY, posZ, dirX, dirY, dirZ, board->mesh_BigGear); // +0x4374
obj->scale = 0.5; // +0x10F4 = 0x3F000000
if (strstr(name, "TOUCH")) obj->touchFlag = 1; // +0x1510
AthenaList_Append(board+0x2578, obj);
ROTATOR (strnicmp "ROTATOR", 7):
obj = operator_new(0x1508);
Rotator_ctor(obj, board, posX, posY, posZ, board->mesh_Rotator); // +0x4378
obj->spinDir = 1.0; // +0x10E8 = 0x3F800000
if (RNG_Rand(0, 2, 0) == 0) obj->spinDir = -1.0; // 0xBF800000 — 50% chance reverse
AthenaList_Append(board+0x2578, obj);
PENDULUM (strnicmp "PENDULUM", 8):
obj = operator_new(0x1504);
Pendulum_ctor(obj, board, posX, posY, posZ, phase, board->mesh_Pendulum); // +0x437C
collisionLevel = obj->CollisionLevel; // +0x10D4
AthenaList_Append(board+0x2578, obj);
Step 5: Constructor Internals
All 5 ctors follow the same pattern:
// 1. Call Stands_ctor(this, mesh)
// → SpriteAnim_Ctor, sets vtable=Level_DeletingDtor, Timer_Init
// → Clones SpatialTree from mesh (geometry + collision)
// → AthenaList_Init on +0x18, +0x488, +0x8A0, +0xCB8
// 2. Set object-specific vtable:
// Looper → vtable = 0x4D54B8 (PopCylinder_DeletingDtor)
// Gear → vtable = 0x4D5AD0 (Gear_Vec3List_DeletingDtor)
// Rotator → vtable = 0x4D5518 (Lifter_DeletingDtor)
// Pendulum→ vtable = 0x4D57D0 (Pendulum_DeletingDtor2)
// 3. AthenaList_Init on object-specific list (+0x10E8/+0x10F0/+0x10F8)
// 4. Set board pointer: obj+0x10D0 = board
// 5. Set position: obj+0x10D8/+0x10DC/+0x10E0 = posX/posY/posZ
// 6. Create CollisionLevel:
// operator_new(0x10D0) → CollisionLevel_ctorWithLevel(col, this)
// obj->CollisionLevel = col (+0x10D4)
// col->field_434 = obj->field_434 (difficulty flag propagation)
// col->field_431 = 0 (collision enabled)
Impossible Race Collision Dispatch (0x00418360)
When the ball touches any collision triangle:
if (ball->is_active) { // ball+0x2E8 (param_2[0x1DA] in Ghidra int* indexing = 0x768)
// N:BOUNCE — bounce physics (same as Impossible Gear bumpers)
if (strnicmp(event, "N:BOUNCE", 8) == 0) {
// Scale velocity, normalize, apply bounce force
}
// N:ONROTATOR — score tracking
if (strnicmp(event, "N:ONROTATOR", 11) == 0) {
ScoreObject_SetScore(board->scoreObj, ball);
}
// N:ONGEAR — catapult-style object attachment
if (strnicmp(event, "N:ONGEAR", 8) == 0) {
Catapult_AddObjectConditional(board->scoreObj, ball);
}
// E:HELPINERTIA — increase ball inertia (0x40200000 = 2.5)
if (stricmp(event, "E:HELPINERTIA") == 0) {
ball->inertia = 0x40200000; // +0x2A4 (param_2[0xA9])
}
// E:UNHELPINERTIA — decrease ball inertia (0x40A00000 = 5.0)
if (stricmp(event, "E:UNHELPINERTIA") == 0) {
ball->inertia = 0x40A00000;
}
}
// Always calls shared handler:
DispatchCollisionEvents(board, ball, collision);
DispatchCollisionEvents (0x0040C5D0) — Shared Event Handler
Processes ALL common collision events:
N:SECRET→ Rotator_MarkTriggeredN:UNLOCKSECRET→ CheckArenaUnlockE:NODIZZY<TIME>N</TIME>→ Ball_DizzyImmunity (no-dizzy timer)E:SAFESWITCH→ copy switch data to ball+0xC2CE:LIMIT→ track arena completionsE:JUMP→ play sound + SetForce(0.1, 1) + impact=10E:ACTION(ONCE/SCORE)→ score trackingE:TRAJECTORY(X,Y,Z)→ set ball trajectoryN:WATER→ water flag + timer=10N:TARPIT→ tar sound + tar flagN:GOAL→ finish raceDROPIN→ sound + score + 200PIPEBONK→ random sound + score + 100POPOUT→ sound + score + 100N:MOUSETRAP→ deflect + rotator collision
3. Global Spawn Pattern
Both CEA scripts use the same proven pattern as the SpeedCylinder spawn script:
Hook Point
Ball_Update @ 0x00405E22 (original: mov eax, [esi+0x0C5C])
→ jmp SpawnCode
esi= Ball pointerball+0x14= Board pointerball+0x18= state (0 = active racing)ball+0x164/+0x168/+0x16C= position X/Y/Z
Spawn Flow
- Save player position every frame (when
ball+0x18 == 0) - Check spawn flag (set via CE address list)
- If flag set:
pushad→ spawn →popad - Spawn process:
a. Get board fromball+0x14
b. Get App+0x174 (D3D device) fromboard+0x878
c.operator_new(0x10D0)→MeshWorld_ctor(mesh, D3D, "filename")
d.operator_new(objSize)→Object_ctor(obj, board, posX, posY, posZ, mesh)
e. Set position atobj+0x10D8/+0x10DC/+0x10E0
f.AthenaList_Append(board+0x2578, obj)— general objects
g.AthenaList_Append(board+0x10EC, collisionLevel)— collision registration
h.AthenaList_Append(board+0x8B0→+0x18, collisionLevel)— spatial tree
i.Timer_Init→vtable[0x58](CallUpdate) →vtable[0x54](CallRender)
j. Track in array for board-change cleanup
Key Addresses
| Address | Function |
|---|---|
| 0x004BA57B | operator_new |
| 0x00461510 | MeshWorld_ctor (RET 0x8) |
| 0x00465080 | CollisionLevel_ctorWithLevel (RET 0x4) |
| 0x00462850 | Stands_ctor (RET 0x4) |
| 0x00453810 | AthenaList_Append |
| 0x00453210 | AthenaList_Init (RET 0x4) |
| 0x00457AD0 | Timer_Init |
| 0x00457A40 | Timer_Cleanup |
| 0x0040B090 | Level_InitScene |
String Constants
| Address | String |
|---|---|
| 0x4CFCA4 | "levels\level8" |
| 0x4D2158 | "Levels\LevelImpossible-Looper" |
| 0x4D213C | "Levels\LevelImpossible-Gear" |
| 0x4D211C | "Levels\LevelImpossible-BigGear" |
| 0x4D20FC | "Levels\LevelImpossible-Rotator" |
| 0x4D20DC | "Levels\LevelImpossible-Pendulum" |
Ctor Calling Conventions (verified via RET N)
| Ctor | RET N | Stack Params | Signature |
|---|---|---|---|
| Looper_ctor (0x435800) | 0x14 | 5 | (this, board, posX, posY, posZ, mesh) |
| Gear_ctor (0x437590) | 0x20 | 8 | (this, board, posX, posY, posZ, dirX, dirY, dirZ, mesh) |
| Rotator_ctor (0x435940) | 0x14 | 5 | (this, board, posX, posY, posZ, mesh) |
| Pendulum_ctor (0x436A20) | 0x18 | 6 | (this, board, posX, posY, posZ, phase, mesh) |
| Stands_ctor (0x462850) | 0x4 | 1 | (this, mesh) |
| MeshWorld_ctor (0x461510) | 0x8 | 2 | (this, D3D, filename) |
🔗 Related Documents
Global Catapults (CEA)
Global Catapults (CEA)
Spawns functional catapults on any level/arena. Ball is launched via the game's native collision system (E:CATAPULTBOTTOM), not proximity detection.
Usage
- Enable the script in Cheat Engine.
- Set
SpawnCatapultto 1 in the address list. - Enter any race or arena.
- A catapult spawns at the ball's position (20 units below).
- Roll over the catapult's
E:CATAPULTBOTTOMsurface — the ball gets launched.
What Changed from v1
| Issue | v1 (Original) | v2 (Fixed) |
|---|---|---|
| Board+0x43B8 crash | Uninitialized AthenaList on non-Tower levels → _realloc(garbage) crash |
Checks vtable vs 0x4D875C, calls AthenaList_Init(0x00453210) if needed |
| Board+0x584C crash | Same uninitialized list crash | Same fix |
| Proximity launch | Checks XZ distance < 9.0 and manually calls Catapult_Launch | Removed entirely — collision system handles launch via E:CATAPULTBOTTOM |
float_9 |
9.0 proximity constant | Removed (unused) |
How Launch Works (Original Game)
- Ball touches
E:CATAPULTBOTTOMcollision triangle on the catapult's CollisionLevel mesh - Collision dispatch fires:
- Race mode:
TowerCollisionEvents(0x0040DCD0) iteratesBoard+0x43B8 - Arena mode:
MasterCollisionEvents(0x00412850) iteratesBoard+0x584C
- Race mode:
- For each catapult in the list, checks
catapult+0x10D4 == *collision_entry - On match: sets
catapult+0x10EC = ball_ptr, callsCatapult_Launch(catapult) Catapult_Launchsets+0x10F0 = 1(active),+0x10F4 = 50(launch timer frames)Catapult_Update(0x0043E600) applies matrix transform each frame, physically launching the ball
Board AthenaList Initialization
Board_ctor (0x00419030) initializes these AthenaLists:
0x08B8, 0x0CD4, 0x10EC, 0x1518, 0x1930, 0x1D48,
0x2160, 0x2578, 0x29D4, 0x2DEC, 0x3204, 0x362C
But Board+0x43B8 and Board+0x584C are only initialized by level-specific ctors:
LevelBoard_Tower_ctor(0x0041E340) → inits +0x43B8, +0x584CBoardLevel_Master_Ctor(0x004206D0) → inits +0x584C
On all other levels these offsets contain garbage from malloc. The fix checks the vtable pointer (first 4 bytes) against 0x4D875C (the AthenaList vtable set by AthenaList_Init). If it doesn't match, the list is uninitialized and AthenaList_Init is called before appending.
Catapult Struct Layout (0x1108 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x0000 | void* | vtable (0x4D4F98 after Catapult_ctor) |
| +0x10D0 | void* | parent Board pointer |
| +0x10D4 | void* | CollisionLevel pointer (matched in collision dispatch) |
| +0x10D8 | float | position X |
| +0x10DC | float | position Y |
| +0x10E0 | float | position Z |
| +0x10E4 | float | rotation |
| +0x10E8 | float | launch direction Y (-1.0 = upward) |
| +0x10EC | void* | ball pointer (set by collision dispatch on launch) |
| +0x10F0 | byte | triggered flag (1 = launch active) |
| +0x10F4 | int | launch timer (50 frames) |
| +0x10F8 | AthenaList | tracked objects (0x418 bytes) |
| +0x1100 | byte | active state (1 = active) |
| +0x1104 | float | launch power (17.0) |
🔗 Related Documents
Global Chomper Mod
Global Chomper Mod
Spawns Tower Race chompers (purple thing in the pit) on any level with a hotkey.
Usage
- Load
GlobalChomper.CEAin Cheat Engine - Enable the script
- Set
SpawnChomperto 1 in CE (or use a hotkey) - Chomper spawns at player 1's position
- Ball touching chomper takes 25.0 damage (E:BITE event) + chomp sound
How It Works
- Hooks at
Ball_Update(0x405E22) - On first spawn: loads
Meshes\Chompermesh viaMeshWorld_ctor→ stores atboard+0x4390 - Creates CollisionLevel from mesh via
CollisionLevel_ctorWithLevel(0x465080) - Loads mesh data via
Level_LoadMeshes(0x465200) - Registers collision with scene manager at 0x4F7360
- E:BITE events are baked into the chomper mesh's collision triangles
- When ball touches chomper → E:BITE → board+0x43A0 = 25.0 (bite damage)
- Game loop reads board+0x43A0 → applies damage to ball → "sounds\chomp" plays
- Registered to board+0x2578 (general), board+0xCD4, scene spatial tree
Object Details
- Type: MeshWorld + CollisionLevel (NOT a game object with vtable)
- Mesh: "Meshes\Chomper" (VA 0x4D094C)
- Sound: "sounds\chomp" (VA 0x4D2D98)
- Collision Event: E:BITE → 25.0 damage (0x41C80000)
- No per-frame update needed — collision events fire automatically
How E:BITE Works
The chomper mesh's collision triangles have E:BITE event names embedded in them.
When the ball intersects these triangles, the game's collision system fires E:BITE:
// In Level_HandleCollision (0x40DCD0):
if (stricmp(eventName, "E:BITE") == 0) {
board+0x43A0 = 25.0; // bite damage
board+0x43A8 = 0; // reset counter
}
The damage value is read by the game loop at 0x4023C1 and applied to the ball.
Full Analysis
See docs/MACE_WINDMILL_CHOMPER_SYSTEM.md for complete reverse-engineering documentation.
🔗 Related Documents
Global Drawbridge Spawner
Global Drawbridge Spawner
Spawns Tower Race drawbridges at Player 1's position in any level via hotkey.
Usage
- Enable script in Cheat Engine
- Press hotkey (set
SpawnDrawbridgeto 1) to spawn a drawbridge at player position - Drawbridges animate (open/close) automatically via vtable[0x2C] update
Root Cause of Original Crash
board+0x4378 is a REUSED SLOT across levels:
| Level | board+0x4378 contains |
|---|---|
| Tower (Level4) | Levels\Level4-Mace mesh (set by LevelBoard_Tower_ctor) |
| Beginner (LevelCascade) | 0 (unused) |
| Other races | varies — may contain other mesh data |
The original script wrote the drawbridge mesh to board+0x4378 permanently and never restored it. In Beginner Race the slot was 0 (unused) so it worked. In Tower Race and other races, the slot already contained another level's mesh, causing:
- Wrong mesh used for drawbridge (Mace mesh instead of Drawbridge mesh)
- Board slot corrupted for original objects → crash when level objects try to use the slot
Fix: JIT Mesh Injection
Save board+0x4378 → inject drawbridge mesh → call constructor → restore board+0x4378.
This is the same pattern as the universal ref loader (see skill hamsterball-dll-modding).
Functions Traced
| Address | Name | Description |
|---|---|---|
| 0x4396F0 | Spinner_Level_ctor | __thiscall, RET 0x14 (5 params). Reads board+0x4378 for mesh. |
| 0x461510 | MeshWorld_ctor | __thiscall, RET 0x8. Loads MESHWORLD file. |
| 0x462850 | Stands_ctor | Clones SpatialTree from mesh source. |
| 0x453810 | AthenaList_Append | __thiscall, RET 4. Adds to list. |
| 0x439870 | Impossible_Level_Update | vtable[0x2C]. Drawbridge animation: timer, rotation, sound. |
| 0x4BA57B | operator_new | __cdecl. Allocates memory. |
| 0x40E250 | CreateSawblade | Game factory. Creates BONK, TOWER, SAWBLADE, BRIDGE, JUDGE, BELL. |
| 0x41E340 | LevelBoard_Tower_ctor | Tower board init. Loads meshes into board+0x43xx slots. |
Board Slot Layout (Tower Race)
| Offset | Mesh | Set by |
|---|---|---|
| board+0x436C | Levels\Level4-Catapult | LevelBoard_Tower_ctor |
| board+0x4370 | Levels\Level4-Drawbridge | LevelBoard_Tower_ctor |
| board+0x4374 | Meshes\YellowLink | LevelBoard_Tower_ctor |
| board+0x4378 | Levels\Level4-Mace | LevelBoard_Tower_ctor |
| board+0x437C | Levels\Level4-Windmill | LevelBoard_Tower_ctor |
| board+0x4390 | Meshes\Chomper | LevelBoard_Tower_ctor |
| board+0x43B4 | Levels\Level4-Turret | LevelBoard_Tower_ctor |
Object Registration
Drawbridges are registered in:
board+0x2578— active objects list (update + render)board+0xCD4— collision objects listboard+0x10EC— collision level listboard+0x8B0+0x18— collision dispatch listboard+0x8AC→+0x480→+0x1C— render list (post-alpha)
Object Structure (Spinner_Level / Drawbridge)
| Offset | Type | Description |
|---|---|---|
| +0x000 | int* | vtable (0x4D51E0) |
| +0x10D0 | int | board pointer |
| +0x10D4 | float | position X |
| +0x10D8 | float | position Y |
| +0x10DC | float | position Z |
| +0x10E0 | float | rotation angle |
| +0x10E4 | int | state (0=initialized) |
| +0x10E8 | int | unknown (0) |
| +0x10F0 | int | timer countdown (100) |
| +0x10F4 | void* | CollisionLevel pointer |
| +0x10F8 | float | rotation direction (1.0 or -1.0) |
🔗 Related Documents
Global Expert Objects Mod
Global Expert Objects Mod
Spawns all 6 expert race objects on any level with a hotkey.
Objects Spawned
- BONK (Hammer) — The hammer that chases you (ctor 0x438850, alloc 0x1200)
- FAN — The fanstorm (ctor 0x438C20, alloc 0x1188)
- SAWBLADE — Saw blade (ctor 0x434660, alloc 0x111C)
- BRIDGE — Breakable bridge (ctor 0x4396F0, alloc 0x10FC)
- JUDGE — Judge object (ctor 0x43A150, alloc 0x1100)
- BELL — Bell to ring (ctor 0x434D70, alloc 0x10E8)
Usage
- Load
GlobalExpert.CEAin Cheat Engine - Enable the script
- Set
SpawnExpertto 1 in CE (or use a hotkey to set it) - All 6 objects spawn at the player's position, spread out
Mesh Loading
Only BRIDGE needs a mesh: Levels\Level5-Bridge loaded via MeshWorld_ctor (cached on first spawn).
All other objects (BONK, FAN, SAWBLADE, JUDGE, BELL) create their geometry internally via Stands_ctor(0x461740) using the D3D device — no external mesh needed.
AthenaList Initialization
On non-Expert levels, three lists are initialized on first spawn:
- Board+0x4380 (bridge list 1)
- Board+0x4798 (bridge list 2)
- Board+0x4BBC (judge list)
Uses vtable check cmp dword [ecx], 0x004D875C to avoid double-init.
Post-Spawn Board Field Writes
- BONK → Board+0x436C (for E:CALLHAMMER event)
- SAWBLADE → Board+0x4370 (for E:ALERTSAW1/ACTIVATESAW1 events)
- BELL → Board+0x4FD4 (for bell ring event)
Collision Events
Expert Race collision handler at 0x40E6A0 handles:
- E:CALLHAMMER, E:HAMMERCHASE — activate hammer chase
- E:ALERTSAW1/2, E:ACTIVATESAW1/2 — activate saw blades
- E:ALERTJUDGES — cycle judges
- E:SCORE — scoring event
- E:JUMP — jump pad
On non-Expert levels, objects have physical collision but special events won't fire.
Hook Point
- Address: 0x405E22 (inside Ball_Update)
- Original:
mov eax, [esi+0x0C5C](6 bytes: 8B 86 5C 0C 00 00)
🔗 Related Documents
Global FunBall Spawner
Global FunBall Spawner
Spawns a real FunBall ball entity at Player 1's position in any level.
Usage
Set SpawnObject to 1 to spawn a FunBall at the player's position.
How the FunBall Works
Mesh Loading
The "Meshes\FunBall" mesh (string at 0x4D3474) is loaded by App_ResourceLoader (TimerDisplay, 0x4298C0) via vtable[0x4C] into App+0x26C as a MeshNode (0x18 bytes).
Ball Mesh Array
The game stores all ball meshes in an array at board+0x244, indexed by ball+0x754 (mesh type index):
| Index | Offset | Mesh Path |
|---|---|---|
| 0 | App+0x244 | Meshes\Sphere |
| 1 | App+0x248 | Meshes\SphereBreak1 |
| 2 | App+0x24C | Meshes\SphereBreak2 |
| 3 | App+0x250 | Meshes\Hamster-Waiting |
| 4 | App+0x254 | Meshes\Hamster-trot1 |
| 5 | App+0x258 | Meshes\Hamster-trot2 |
| 6 | App+0x25C | Meshes\Hamster-trot3 |
| 7 | App+0x260 | Meshes\RBGlare |
| 8 | App+0x264 | Meshes\Sphere+Tar |
| 9 | App+0x268 | Meshes\8Ball |
| 10 | App+0x26C | Meshes\FunBall |
| 11 | App+0x270 | Meshes\Bell |
| 12 | App+0x274 | Meshes\Dizzy |
Ball Construction
- Ball_ctor (0x40AFE0): alloc 0xC98,
__thiscall(this, board)- Calls
Ball_ctor2which initializes all fields includingball+0x754 = 0(Sphere) - Sets vtable to 0x4CF3A0
- Calls
- After construction, set
ball+0x754 = 10to use the FunBall mesh
Rendering
Ball_Render (0x402DE0) and Ball_RenderShadow (0x401B00) read board+0x244[ball+0x754] to select the mesh for rendering. The render function calls mesh->vtable[0x07](matrix, 0) to render.
Registration
Balls are registered in two lists:
board+0x29D4— ball list (for updates and rendering)board+0x2DEC— collision list (for physics)
Ball Key Fields
| Offset | Type | Description |
|---|---|---|
| +0x000 | int* | vtable (0x4CF3A0) |
| +0x010 | int | board pointer |
| +0x014 | int | scene pointer |
| +0x164 | float | position X |
| +0x168 | float | position Y |
| +0x16C | float | position Z |
| +0x281 | byte | unused_init_flag (DEAD: set by ctor, never read) |
| +0x284 | float | radius (26.0) |
| +0x2F9 | byte | is_falling (0=not falling) |
| +0x2FC | float | fall_timer (1.0) |
| +0x754 | int32 | mesh type index (10=FunBall) |
| +0xC74 | int32 | AI chase target (0=none) |
| +0xC80 | byte | flag (0) |
🔗 Related Documents
Global Judge (Hammy Judge) Mod
Global Judge (Hammy Judge) Mod
Spawns Expert Race Hammy Judges on any level with a hotkey.
Usage
- Load
GlobalJudge.CEAin Cheat Engine - Enable the script
- Set
SpawnJudgeto 1 in CE (or use a hotkey) - Judge spawns at player 1's position and animates
How It Works
- Hooks at
Ball_Update(0x405E22) - Allocates 0x1100 bytes, calls
Judge_Ctor(0x43A150) - Constructor internally calls
Level_ctorwhich loads "meshes\hammyjudge" mesh - Sets vtable to 0x4D52B8
- Sets position at
obj+0x10D4(X),+0x10D8(Y),+0x10DC(Z) - Registered to
board+0x4BBC(judge list),board+0x2578(general),board+0xCD4 - Per-frame: calls vtable[11] (Judge_Update @ 0x434B60) for animation
Object Details
- Alloc size: 0x1100 (4352 bytes)
- Constructor: Judge_Ctor (0x43A150) — thiscall(obj, board) ret 4
- Vtable: 0x4D52B8
- Mesh: "meshes\hammyjudge" (VA 0x4D0AA8) — loaded internally by Level_ctor
- Update: vtable[11] @ 0x434B60 — trig oscillation (same pattern as Pendulum)
- Render: vtable[18] @ 0x43A270
- No JIT mesh injection needed — Level_ctor loads the mesh internally
Collision Events
| Event | Action |
|---|---|
| E:ALERTJUDGES | Activates all judges in board+0x4BBC list |
| E:BELL | Rings bell sound |
| E:SCORE | Awards points |
Full Analysis
See docs/WINDMILL_JUDGE_SYSTEM.md for complete reverse-engineering documentation.
🔗 Related Documents
Global Lifter (DropLift) Spawn
Global Lifter (DropLift) Spawner
Spawns a Lifter at Player 1's position in any level. The lifter rises when the ball approaches — replicating the Up Race lifter with E:DROPLIFT behavior globally.
Files
GlobalLifterDropLift.CEA— CE AutoAssembler script (pure CEA, no Lua)
Installation
- Copy to your Cheat Engine scripts folder
- In CE: File → Load → select
GlobalLifterDropLift.CEA - Enable the script
Usage
- Add the address
SpawnLifterto your CE address list - Enter a race or arena (any level works)
- Set
SpawnLifterto1— a Lifter spawns at Player 1's current position - The flag auto-resets to
0after spawning - Set to
1again to spawn another
The lifter automatically rises when the ball gets within 100.0 units.
How It Works
Lifter Creation Chain
The original game creates lifters through this chain:
- Board constructor loads
Levels\Level6-Liftermesh intoApp+0x5C8(global lifter mesh slot) CreateLevelObjects(0x4121D0) dispatches toTryCreateLifter(0x40EC40)TryCreateLifterchecks if object name =="LIFTER"(strnicmp, 6 chars)- If match:
operator_new(0x10FC)→Lifter_ctor(0x434E60)→ register inboard+0x2578
Lifter_ctor (0x434E60)
__thiscall: ECX=this, push board, push x, push y, push z (ret 0x10)
- Gets lifter mesh from
board→[board+0x878]→[App+0x5C8] - Calls
Stands_ctor(0x462850)— clones SpatialTree from the lifter mesh for rendering - Sets vtable =
0x4D5390(Lifter vtable) - Copies position to
+0x10D8(x, y, z as 3 floats) - Creates
CollisionLevel(0x10D0 bytes) viaCollisionLevel_ctorWithLevel(0x465080) - Stores collision mesh at
+0x10D4 - Sets state
+0x10E4 = 3(WAITING) - Sets
+0x10F0 = -1(no current collision target)
State Machine (vtable[0x2C] = 0x434F60)
| State | Name | Behavior |
|---|---|---|
| 3 | WAITING | Initial state, lifter at bottom, doing nothing |
| 0 | RISING | Height increases (+0x10E8 accumulates to 95.0 max) |
| 1 | DESCENDING | Height decreases back to 0, plays sound every 20 frames |
| 2 | DROP | Quick descent (not used in normal operation) |
Key fields:
+0x10D8/+0x10DC/+0x10E0— position (x, y, z)+0x10E4— state (0-3)+0x10E8— current height offset (0=bottom, 95.0=top)+0x10EC— sound timer (counts down from 20)+0x10F0— last height (for collision detection)+0x10F4— descent/wait timer+0x10F8— rise speed accumulator
DropLift Trigger
When the ball touches an E:DROPLIFT collision surface in the level mesh:
- Collision handler at
0x0040F06Cchecks for"E:DROPLIFT"event - Gets lifter pointer from
board+0x436C - Calls
Lifter_TriggerDrop(0x435170)—__thiscall: ECX=lifter Lifter_TriggerDroponly fires if state == 3 (WAITING)- Sets state = 0 (RISING), rise_speed = 0.25
- Plays sound at lifter position via
Sound_Play3D
The NOP Patch (Hamsterball.exe+EC74)
The user's existing patch at 0x0040EC74 NOPs a JNE instruction in TryCreateLifter:
- Original:
JNE 0x0040ED01(skip if name != "LIFTER") - Patched:
NOP × 6(always create lifter, regardless of name)
This makes the Lifter factory accept ANY mesh object name, causing lifters to be created for all objects in the odd race. The CEA script takes a different approach — it directly creates the lifter without going through the factory.
Verified Addresses (Ghidra/r2, 2026-06-25)
| Address | Function | Convention |
|---|---|---|
| 0x40EC40 | TryCreateLifter | ECX=board, stack=[name, out1, out2, pos], RET 0x10 |
| 0x434E60 | Lifter_ctor | ECX=this, stack=[board, x, y, z], RET 0x10 |
| 0x434F60 | Lifter_Update (vtable[0x2C]) | ECX=this |
| 0x435170 | Lifter_TriggerDrop | ECX=lifter |
| 0x462850 | Stands_ctor | ECX=this, push source_mesh |
| 0x465080 | CollisionLevel_ctorWithLevel | ECX=new, push source_mesh |
| 0x4BA57B | operator_new | push size, RET |
| 0x453810 | AthenaList_Append | ECX=list, push item, RET 0x4 |
| 0x461510 | MeshWorld_ctor | ECX=this, push d3d, push path, RET 0x8 |
| 0x459860 | Sound_Play3D | push pos_vec, push vol, ECX=sound_obj |
| 0x4D5390 | Lifter vtable | — |
| 0x4D3308 | "Levels\Level6-Lifter" string | — |
| 0x4CFB90 | "E:DROPLIFT" string | — |
| 0x4CFB1C | "LIFTER" string | — |
🔗 Related Documents
Global Lifters Mod
Global Lifters Mod
Spawns Up Race lifters (Rotators) on any level with a hotkey.
Usage
- Load
GlobalLifters.CEAin Cheat Engine - Enable the script
- Set
SpawnLifterto 1 in CE (or use a hotkey to toggle it) - The next time
Ball_Updateruns (every frame), a lifter spawns at the player's position
How It Works
- Hooks
Ball_Updateat0x00405E22(inside the player physics update) - When
SpawnLifter == 1:- Saves player position from ball fields (+0x164/+0x168/+0x16C)
- Creates a temporary
MeshWorldfrom"levels\levelup-lifter"(the Up Race lifter mesh) - Stores it at
Board+0x4784(the Up Race lifter mesh field) - Calls
CreateUpLevelObjects(0x4117B0) to spawn a Rotator at the player position - Registers the Rotator in 4 lists:
Board+0xCD4(timer/cleanup list)Board+0x8AC+0x480+0x1C(SceneObject render list)Board+0x10EC(timer list)Board+0x8B0+0x18(CollisionLevel collision list)
- Calls
SceneObject_CallUpdate(vtable[0x58]) andSceneObject_CallRender(vtable[0x54])
v2 Fix — Dizzy Race Crash
Crash address: 0001:0001C5F0 (near-null access violation)
Root cause: Stack corruption from an unmatched push ebx before the SceneObject_CallRender call.
SceneObject_CallRender (0x45DF90) is a __fastcall(ecx) tail-call — it takes zero stack parameters and does not clean up any stack. The push ebx before the call left ESP off by 4 bytes. When add esp, 68 executed, it only cleaned 68 of 72 bytes on the stack. popad then read from shifted positions, giving ESI the value of EBP instead of the ball pointer. The original instruction mov eax, [esi+0x0C5C] then accessed [EBP + 0x0C5C].
On most levels, EBP happened to point to valid memory, so the corrupted read silently succeeded (wrong value, no crash). On Dizzy Race, EBP was a near-null pointer (0x0001B994), producing the access violation at 0x0001B994 + 0x0C5C = 0x0001C5F0.
Fix: Removed the push ebx line. Stack is now perfectly balanced — add esp, 68 exactly matches sub esp, 68, and popad restores all registers correctly.
🔗 Related Documents
Global Mace (Pendulum) Mod
Global Mace (Pendulum) Mod
Spawns Tower Race swinging maces on any level with a hotkey.
Usage
- Load
GlobalMace.CEAin Cheat Engine - Enable the script
- Set
SpawnMaceto 1 in CE (or use a hotkey on that address) - Mace spawns at player 1's position (slightly above for hang-down swing)
- Each hotkey press spawns another mace (up to 16)
How It Works
- Hooks at
Ball_Update(0x405E22) — same pattern as SpeedCylinder/Bonk - On first spawn: loads
Levels\Level4-Macemesh viaMeshWorld_ctor→ stores atboard+0x4378 - On Tower Race, mesh is already loaded by
LevelBoard_Tower_ctor— reuses it - Allocates 0x110C bytes, calls
CascadeStands_Ctor(0x438750) - Sets position at
obj+0x10D8(X),+0x10DC(Y),+0x10E0(Z) - Sets swing amplitude (+0x10E8 = 80.0), active flag (+0x10F4 = 1), timer (+0x10F8 = 50)
- Registered to
board+0x2578(general),board+0x5000(mace list),board+0xCD4 - Per-frame: calls vtable[11] (Pendulum_Update @ 0x43F3C0) for swing animation
Object Details
- Alloc size: 0x110C (4364 bytes)
- Constructor: CascadeStands_Ctor (0x438750) — thiscall(obj, board, mesh) ret 8
- Vtable: 0x4D50C0
- Mesh: "Levels\Level4-Mace" (VA 0x4D0974)
- Update: vtable[11] @ 0x43F3C0 — swing animation with sin/cos oscillation
- Render: vtable[18] @ 0x45E0E0 — shared base render
Key Fields
| Offset | Type | Value | Description |
|---|---|---|---|
| +0x10D8 | float | player X | Current position X |
| +0x10DC | float | player Y + 20 | Current position Y (elevated) |
| +0x10E0 | float | player Z | Current position Z |
| +0x10E8 | float | 80.0 | Swing amplitude (radius) |
| +0x10F4 | int | 1 | Active flag |
| +0x10F8 | int | 50 | Timer |
Collision
- N:MACE — ball touching mace gets bounced (calls ball->vtable[8])
- E:MACETRIGGER — activates mace swing (sets +0x10F0=1)
- Both handled in
Level_HandleCollision(0x40DCD0) viaboard+0x5000mace list
Notes
- Position offset is +0x10D8 (not +0x10D4 like most other objects)
- Constructor takes 2 params (board + mesh), unlike Bonk/Sawblade which take position params
- CascadeStands_Ctor internally calls Stands_ctor + allocates CollisionLevel
- Swing animation uses amplitude 80.0 and 0.05/frame angle increment (~71.5°/sec at 25fps)
Full Analysis
See docs/MACE_WINDMILL_CHOMPER_SYSTEM.md for complete reverse-engineering documentation of Mace, Windmill, and Chomper systems.
🔗 Related Documents
Global Neon Mod
Global Neon Mod — Spawn Objects + Neon Lighting
Two independent features, toggled via CE address symbols:
- Spawn Neon Objects — Spawns 6 neon race objects at ball position
- Neon Lighting — Darkens the scene and attaches a point light to the ball
Controls
| Symbol | Address (CE) | Default | Description |
|---|---|---|---|
SpawnNeon |
alloc'd | 0 | Set to 1 to spawn neon objects at ball position |
NeonLighting |
alloc'd | 1 | 1 = neon lighting ON (dark scene + ball light), 0 = OFF |
NeonAmbient |
alloc'd | 0x000C0C14 | Ambient color (0x00RRGGBB). Lower = darker. Set to 0 for pitch black. |
Feature 1: Neon Object Spawning
Spawns all 6 neon race objects on any level with a flag.
Objects Spawned
- NEONPLATFORM — Disappearing neon floor (alloc 0x10EC, ctor 0x43E110)
- DFLOOR1 — Disappearing floor 1 (alloc 0x1104, ctor 0x43E450)
- DFLOOR2 — Disappearing floor 2
- DFLOOR3 — Disappearing floor 3
- DFLOOR4 — Disappearing floor 4
- TRODE — Neon electrode/tube (alloc 0x1104, ctor 0x43E450)
Usage
- Load
GlobalNeon.CEAin Cheat Engine - Enable the script
- Set
SpawnNeonto 1 in CE (or use a hotkey to set it) - All 6 objects spawn at the player's position, spread out in a line
Mesh Loading
All 6 meshes are loaded on first spawn (cached):
Levels\LevelDark-NeonPlatform→ mesh_neonplatformLevels\LevelDark-DFloor1→ mesh_dfloor1Levels\LevelDark-DFloor2→ mesh_dfloor2Levels\LevelDark-DFloor3→ mesh_dfloor3Levels\LevelDark-DFloor4→ mesh_dfloor4Levels\LevelDark-Trode→ mesh_trode
Object Placement
- NEONPLATFORM: at player position (X, Y, Z)
- DFLOOR1: Y+20 (above player)
- DFLOOR2: X+20
- DFLOOR3: X+40
- DFLOOR4: X+60
- TRODE: Z+20
Feature 2: Neon Lighting
Replicates Neon Race's dark atmosphere on any level. The scene background and ambient are darkened, and a D3D point light is attached to the ball position.
How It Works
The hook intercepts Graphics_RenderScene (0x454BC0) per frame:
- Darkens ambient — Overrides gfx+0x730 (ambient color) with
NeonAmbientvalue - Disables fog — Sets gfx+0x734 = 0, gfx+0x738 = black
- Enables D3D lighting — Calls
SetRenderState(D3DRS_LIGHTING, TRUE) - Sets point light at ball position — Reads ball XYZ from gfx+0x854/858/85C (populated by
Scene_Render), updates a pre-allocatedD3DLIGHT8structure, and callsSetLight(7, ...)+LightEnable(7, TRUE)on the D3D device
Light Properties
| Property | Value |
|---|---|
| Type | D3DLIGHT_POINT |
| Diffuse | (0.8, 0.9, 1.0) — cool white-cyan |
| Specular | (0.3, 0.4, 0.5) — faint highlight |
| Range | 500.0 |
| Attenuation0 | 0.0 (no constant) |
| Attenuation1 | 0.02 (linear falloff) |
| Light Index | 7 (unused by game's default lights) |
Tuning
- Darkness: Change
NeonAmbientin CE.0x00000000= pitch black,0x00202020= dim,0x00404040= moderate. - Light brightness: Edit the
light_datadiffuse values in the script (offsets 4-12 in the D3DLIGHT8 struct). - Light range: Edit offset 64 in
light_data(float). 500.0 = covers visible area, 250.0 = tight spotlight. - Light falloff: Edit offset 72 (Attenuation1). Higher = shorter range. 0.02 = gradual, 0.05 = steep.
Hook Points
| Hook | Address | Function | Original Bytes |
|---|---|---|---|
| 1 (spawn) | 0x405E22 | Ball_Update | 8B 86 5C 0C 00 00 (6 bytes) |
| 2 (lighting) | 0x454BC0 | Graphics_RenderScene | 81 EC C0 00 00 00 (6 bytes) |
Technical Notes
- The lighting hook fires once per frame, before
Graphics_SetupLightsis called by the original function. This means the game's own lighting setup runs afterward, but our ambient/light overrides persist because we write directly to the gfx struct and D3D device state. - Ball position (gfx+0x854/858/85C) is written by
Scene_Render(0x41A2E0) each frame beforeGraphics_RenderSceneis called, so the light position is always current. - Light index 7 is used to avoid conflicts with the game's own light setup (which uses indices 0-3 at most).
- When
NeonLightingis set to 0, the hook disables light 7 and lets the game's original lighting run unmodified. D3DLIGHT8struct is 104 bytes (not 88 — includes Direction vector between Position and Range). Position is at offset 52 (three floats). The struct is pre-filled in the script with all necessary fields; only Position.x/y/z are updated per-frame.
🔗 Related Documents
Global Sawblade Mod
Global Sawblade Mod
Spawns Expert Race sawblades on any level with a hotkey.
Usage
- Load
GlobalSaw.CEAin Cheat Engine - Enable the script
- Set
SpawnSawto 1 in CE (or use a hotkey) - Saw spawns at player 1's position and auto-activates
How It Works
- Hooks at
Ball_Update(0x405E22) - On spawn: allocates 0x111C bytes, calls
Sawblade_Level_Ctor(0x434660) - Constructor loads
Meshes\sawblademesh internally (no pre-loaded mesh needed) - Auto-activates: sets +0x110D=0 (clear alert) and +0x1114=1 (activated)
- Direction: +0x10F8=1 (Z-axis movement)
- Per-frame: calls vtable[11] (Sawblade_Update @ 0x439BB0) for spin, debris, collision, movement
- Registered to board+0x2578 (general list) and board+0xCD4
Object Details
- Alloc size: 0x111C (4380 bytes)
- Constructor: 0x434660 (Sawblade_Level_Ctor, thiscall, params: board, x, y, z)
- Vtable: 0x4D5240
- Mesh: "Meshes\sawblade" (loaded internally by Level_ctor)
- Update: vtable[11] @ 0x439BB0
- Render: vtable[18] @ 0x4347E0
Key Fields
| Offset | Description |
|---|---|
| +0x10D0 | Board pointer |
| +0x10D4/+0x10D8/+0x10DC | Current position XYZ |
| +0x10E0/+0x10E4/+0x10E8 | Home position XYZ |
| +0x10F0 | Rotation angle (RNG 0-360) |
| +0x10F4 | Spin speed (0→25.0) |
| +0x10F8 | Direction (1=Z-axis, 2=X-axis) |
| +0x10FC | Debris spawn counter |
| +0x110D | Alert flag |
| +0x1110 | Spin velocity (500.0 init, decays ×0.95) |
| +0x1114 | Activated flag (0=idle, 1=active) |
| +0x1118 | Movement velocity |
No Difficulty Gate
The original factory checks App+0x23C != 0 (Normal/Frenzied only).
This script bypasses that check — saws spawn on ALL difficulty levels.
Full Analysis
See docs/SAW_DRAWBRIDGE_SYSTEM.md for complete reverse-engineering documentation.
🔗 Related Documents
Global Sky Race Object Spawner
Global Sky Race Object Spawner — FUNBALL, PILLAR, MAGNIFYER
Spawns Sky Race objects at Player 1's position in any level via hotkey.
Usage
- Set
ObjectType(1-3):- 1 = FUNBALL (PopCylinder — blue ball that pops up near goal)
- 2 = PILLAR (Platform — pillar that pops up and blocks path)
- 3 = MAGNIFYER (CollisionLevel_Spatial — magnifying glass lens effect)
- Set
SpawnObjectto 1 to spawn at player position
Object Details
FUNBALL (PopCylinder)
The blue ball that pops up near the goal in Sky Race.
- Constructor:
PopCylinder_ctor(0x436EE0), alloc 0x10E8 - RET: 0x14 (5 params: board, X, Y, Z, mesh)
- vtable: 0x4D58F0
- Mesh:
"levels\level9-popcylinder1"(string at 0x4D0F5C) - Stands_ctor → renders automatically
- Update (vtable[0x0B/+0x2C] = 0x43DED0): if
+0x10E4flag set → position update, then clear flag. SAFE. - Fields: +0x10D0=board, +0x10D4=X, +0x10D8=Y, +0x10DC=Z, +0x10E0=CollisionLevel, +0x10E4=1(active)
Note on "Meshes\FunBall": The string "Meshes\FunBall" (0x4D3474) is the ball sphere MESH MODEL loaded by the resource loader (App_ResourceLoader at 0x4298C0) into App+0x26C as a MeshNode (0x18 bytes). This is NOT a level object — it's the ball's sphere model used for rendering. It's loaded alongside Sphere (0x244), 8Ball (0x268), Bell (0x270), and Dizzy (0x274). The visible "funball" object in Sky Race that pops up near the goal is actually a POPCYLINDER created from the MESHWORLD file "levels\level9-popcylinder1".
PILLAR (Platform)
The pillars that suddenly pop up and block your path in Sky Race and Sky Arena.
- Constructor:
Platform_ctor(0x4363F0), alloc 0x10F4 - RET: 0x14 (5 params: board, X, Y, Z, mesh)
- vtable: 0x4D56A8
- Mesh:
"levels\level9-popcylinder1"(string at 0x4D0F5C) - Stands_ctor → renders automatically
- Update (vtable[0x0B/+0x2C] = 0x436540): state machine (0=idle, 1=descending, 2=waiting, 3=ascending)
- Pops up/down using
Gfx_SetPositionfor vertical movement - Timer at +0x10F0, state at +0x10EC. SAFE.
- Pops up/down using
- Fields: +0x10D0=board, +0x10D4=X, +0x10D8=Y, +0x10DC=Z, +0x10E0=CollisionLevel, +0x10E4=45.0, +0x10E8=-1.0
MAGNIFYER (Magnifying Glass)
The magnifying glass lens effect in Sky Race.
- Constructor:
CollisionLevel_Spatial_Ctor(0x436250), alloc 0x444 - RET: 0x10 (4 params: board, X, Y, Z) — NO mesh parameter!
- vtable: 0x4D569C (Pendulum variant)
- INVISIBLE — creates a collision sphere (radius 90.0), NOT a visible mesh
- Creates a lens effect that magnifies the ball when it passes through
- Gate: Only created if
difficulty != 0(App+0x23C) - Fields: +0x04=board, +0x08=X, +0x0C=Y, +0x10=Z, +0x24=90.0, +0x28=90.0
Factory Chain
LevelBoard_Sky_ctor (0x41F930):
LoadRaceData("SKYRACE")
board+0x436C = MeshNode("meshes\skypillar")
board+0x4370 = MeshNode("meshes\magnifyingglass")
board+0x4384 = MeshWorld("levels\level9-popcylinder1")
board+0x4388 = MeshWorld("levels\level9-popcylinder2")
board+0x438C = MeshWorld("levels\level9-trapdoor")
Scene_SetupLevel9 (0x410830):
"PILLAR" → collected into board+0x4394 (AthenaList) for later popping
"MAGNIFYER" → CollisionLevel_Spatial_Ctor → board+0x46AC (difficulty gate)
"CLOUDSCAPE" → position lookup via AthenaHashTable
Sky Factory (0x410AD0, vtable[0x21]):
"POPCYLINDER" → Platform_ctor with board+0x4384 mesh
"TRAPDOOR" → Rotator_ctor with board+0x438C mesh
CreateLevelObjects (0x4121D0):
"POPCYLINDER" → PopCylinder_ctor with mesh param
Mesh String Addresses
| Address | String | Usage |
|---|---|---|
| 0x4D0F5C | levels\level9-popcylinder1 |
FUNBALL mesh (MESHWORLD) |
| 0x4D0F40 | levels\level9-popcylinder2 |
FUNBALL2 mesh (MESHWORLD) |
| 0x4D0F90 | meshes\skypillar |
PILLAR mesh (MeshNode) |
| 0x4D0F78 | meshes\magnifyingglass |
MAGNIFYER mesh (MeshNode) |
| 0x4D0F28 | levels\level9-trapdoor |
TRAPDOOR mesh (MESHWORLD) |
| 0x4D3474 | Meshes\FunBall |
Ball sphere model (MeshNode, App+0x26C) |
Registration Pattern (same as SpeedCylinder script)
- Create MeshWorld via
MeshWorld_ctor(alloc 0x10D0) - Store mesh in board slot
board+0x4788 - Call factory:
CreateSpeedCylinder(0x4117B0) for FUNBALL - Register in
board+0xCD4(post-alpha render list) - Register in
board+0x10EC(collision list) - Call
vtable[0x16](update) andvtable[0x15](render) on new object
🔗 Related Documents
Global Toob Race Object Spawne
Global Toob Race Object Spawner — SAW, SAW2, FALLOUT1, SPINNY, BLOCKDAWGS
Spawns Toob Race objects at Player 1's position in any level.
Usage
- Set
ObjectType(1-6):- 1 = SPINNY (rotating platform)
- 2 = SAW (big saw blade)
- 3 = SAW2 (small saw blade)
- 4 = FALLOUT1 (fallout floor)
- 5 = BLOCKDAWG1 (block dog enemy 1)
- 6 = BLOCKDAWG2 (block dog enemy 2)
- Set
SpawnObjectto 1 to spawn at player position
How Each Object Is Created and Works
Factory Function (0x40FB28)
All Toob Race mechanical objects are created by a single unlabeled factory function at 0x40FB28. It uses __strnicmp to match ref names from the MESHWORLD file:
| Ref Name | Constructor | Alloc | RET | Mesh | Mesh Slot | Path |
|---|---|---|---|---|---|---|
| SPINNY | Rotator_ctor (0x435940) | 0x1508 | 0x14 | Level8-Spinny | board+0x436C | — |
| SAW | Stands_CtorCollision (0x43B780) | 0x1110 | 0x18 | Level8-Saw | board+0x4370 | SAWPATH |
| SAW2 | Stands_CtorSpeedCylinder (0x43BE20) | 0x1118 | 0x18 | Level8-Saw | board+0x4370 | SMALLSAWPATH |
| FALLOUT1 | Stands_CtorCollisionV2 (0x43BBC0) | 0x10E8 | 0x14 | Level8-Fallout | board+0x4374 | — |
| BLOCKDAWG1 | Blockdawg_ctor (0x43C310) | 0x1154 | 0x18 | Level8-Blockdawg1 | board+0x4378 | DAWGPATH1 |
| BLOCKDAWG2 | Blockdawg_ctor (0x43C310) | 0x1154 | 0x18 | Level8-Blockdawg1 | board+0x4378 | DAWGPATH2 |
SPINNY (Rotator_ctor)
- Constructor:
Rotator_ctor(this, board, X, Y, Z, mesh)— 5 stack params, RET 0x14 - Mesh:
Levels\Level8-Spinny(string at 0x4D0E38) - vtable: 0x4D5518 (Lifter vtable)
- CollisionLevel: +0x10D4
- Position: +0x10D8 (X), +0x10DC (Y), +0x10E0 (Z)
- Update (vtable[0x0B/+0x2C]): Catapult_Update (0x43E600) — rotates platform, transforms balls riding on it
- No path needed — safe for global spawn
SAW (Stands_CtorCollision)
- Constructor:
Stands_CtorCollision(this, board, X, Y, Z, mesh, pathId)— 6 stack params, RET 0x18 - Mesh:
Levels\Level8-Saw(string at 0x4D0E24) - vtable: 0x4D5578 (Button vtable)
- pathId: stored at +0x10D8
- Update (0x43B8E0):
- Reads
+0x110C(byte flag). If 0 → skips entire update (safe, no path access) - If +0x110C != 0: reads
+0x10F4(state=2), uses+0x10D8(pathId) → crashes if 0
- Reads
- CRASH FIX: Set
+0x110C = 0after construction → update does nothing, object renders but stays static
SAW2 (Stands_CtorSpeedCylinder)
- Constructor:
Stands_CtorSpeedCylinder(this, board, X, Y, Z, mesh, pathId)— 6 params, RET 0x18 - Calls Stands_CtorCollision first, then overrides:
- vtable → 0x4D5CA0 (Button variant)
- +0x10F0 = 0xC2C80000 (float)
- +0x1110 = 1 (flag)
- +0x1114 = 0x3F800000 (1.0)
- Update (0x43BEB0): Same
+0x110Ccheck → if 0, skips (safe) - CRASH FIX: Set
+0x110C = 0after construction
FALLOUT1 (Stands_CtorCollisionV2)
- Constructor:
Stands_CtorCollisionV2(this, board, X, Y, Z, mesh)— 5 params, RET 0x14 - Mesh:
Levels\Level8-Fallout(string at 0x4D0E0C) - vtable: 0x4D55D8 (SpeedCylinder vtable)
- Update (0x43BD10): Reads
+0x10D8(pathId pointer), if null → skips path code gracefully - No crash fix needed — handles null pathId automatically
BLOCKDAWG1/2 (Blockdawg_ctor)
- Constructor:
Blockdawg_ctor(this, board, X, Y, Z, mesh, pathId)— 6 params, RET 0x18 - Mesh:
Levels\Level8-Blockdawg1(string at 0x4D0DF0, same for both) - vtable: 0x4D5638 (ArenaObject vtable)
- pathId: stored at +0x10F0
- Update (0x43C4E0, ArenaObject_Update):
- If
+0x1150 == 0(awake): follows path viaPath_GetPosition(+0x10F0)→ crashes if 0 - If
+0x1150 != 0(sleeping): plays wake-up sound + creates particle ring → safe
- If
- CRASH FIX: Set
+0x1150 = 1(sleeping mode) - BLOCKDAWG2 difference: Also sets
+0x1152 = 1(is_blockdawg2 flag, set by factory)
Rendering
All constructors call Stands_ctor(this, mesh) which clones the SpatialTree from the mesh. The mesh is added to the board's SpatialTree automatically. The board's render pipeline renders it through normal opaque/translucent passes — no manual render list registration needed.
This is why the user's Judge script (using Gear_Level_ctor → Level_ctor → empty mesh) showed nothing: Level_ctor doesn't clone a SpatialTree, so the board has no mesh to render. The Toob objects use Stands_ctor which properly sets up the mesh.
Registration
Factory code adds each object to board+0x2578 (active objects list) via AthenaList_Append (0x453810). The board's update loop iterates this list and calls vtable[0x0B] (+0x2C) for each object each frame.
String Table (0x4CFC80+)
| Address | String | Purpose |
|---|---|---|
| 0x4CFCB4 | DAWGPATH3 | Path for BLOCKDAWG3 |
| 0x4CFCC0 | BLOCKDAWG3 | Ref name (unused in standard Toob) |
| 0x4CFCCC | DAWGPATH2 | Path for BLOCKDAWG2 |
| 0x4CFCD8 | BLOCKDAWG2 | Ref name |
| 0x4CFCE4 | DAWGPATH1 | Path for BLOCKDAWG1 |
| 0x4CFCF0 | BLOCKDAWG1 | Ref name |
| 0x4CFCFC | FALLOUT1 | Ref name |
| 0x4CFD08 | SMALLSAWPATH | Path for SAW2 |
| 0x4CFD18 | SAW2 | Ref name |
| 0x4CFD20 | SAWPATH | Path for SAW |
| 0x4CFD28 | SAW | Ref name |
| 0x4CFD2C | SPINNY | Ref name |
🔗 Related Documents
Global Trapdoor Mod
Global Trapdoor Mod
Spawns Tower Race trapdoors on any level with a hotkey.
Usage
- Load
GlobalTrapdoor.CEAin Cheat Engine - Enable the script
- Set
SpawnTrapdoorto 1 in CE (or use a hotkey on that address) - The trapdoor spawns at the player's position (slightly above)
How It Works
- Hooks at
Ball_Update(0x405E22) — same as Global Lifters - No mesh loading needed — trapdoor meshes are globally pre-loaded at App level
- Initializes
AthenaListat Board+0x47D0 (trapdoor list) if not already initialized - Allocates 0x10F8 bytes, calls
GlassStands_Ctor(alloc, Board)@ 0x438290 - Sets position at obj+0x10E0/+0x10E4/+0x10E8
- Appends to:
- Board+0x2578 (general objects list)
- Board+0x47D0 (trapdoor list — for N:TRAPDOOR collision)
- Board+0xCD4 (sub-object 1: Stands/Trapdoor2 mesh)
- Board+0x10EC (sub-object 2: TipperVisual/Trapdoor2 collision)
- Board+0x8AC→+0x480→+0x1C (primary collision level)
- Board+0x8B0→+0x18 (secondary collision level)
Mesh Dependency
NONE! All trapdoor meshes are globally pre-loaded by the resource loader (0x4298C0):
- App+0x594 = MeshWorld("Levels\Level4-Trapdoor1")
- App+0x598 = MeshWorld("Levels\Level4-Trapdoor2")
- App+0x59C = CollisionLevel(App+0x594)
- App+0x5A0 = CollisionLevel(App+0x598)
Collision
Handled in TowerCollisionEvents (0x40DCD0):
- "N:TRAPDOOR" →
Trapdoor_Activate(obj)@ 0x438410 — sets obj+0x10F4=10, plays sound - "E:OPENSESAME" →
Trapdoor_Open(obj)@ 0x4344D0 — opens first trapdoor in drawbridge list
Sub-objects at obj+0x10D8 and obj+0x10DC are appended to Board collision level lists
for physical ball-vs-trapdoor collision.
Addresses
| Symbol | Address |
|---|---|
| GlassStands_Ctor | 0x438290 |
| operator_new | 0x4BA57B |
| AthenaList_Init | 0x453210 |
| AthenaList_Append | 0x453810 |
| App+0x594 (Trapdoor1 mesh) | global |
| App+0x598 (Trapdoor2 mesh) | global |
| App+0x59C (Trapdoor1 collision) | global |
| App+0x5A0 (Trapdoor2 collision) | global |
| Board+0x47D0 (trapdoor list) | — |
🔗 Related Documents
Global Variables
Hamsterball Global Variables
This document catalogs every global variable in the Hamsterball.exe binary,
derived from Ghidra analysis. Variables are organized by category.
Memory Layout
| Section | Range | Description |
|---|---|---|
| .text | 0x00401000 - 0x004CEFFF | Code (executable instructions) |
| .rdata | 0x004CF000 - 0x004F6FFF | Read-only data (strings, vtables, IAT) |
| .data | 0x004F7000 - 0x00536AF3 | Mutable global variables |
| .data1 | 0x00537000 - 0x00537FFF | Additional data |
| .rsrc | 0x00538000 - 0x0058FFFF | Resources (icons, cursors, DLL) |
1. Game Engine Globals
These are the primary game state variables used by the Hamsterball engine.
g_App
- Address:
0x004FD680 - Type:
Appstruct (2328 bytes / 0x918 bytes) - Description: The central application object. Contains all global game state including
the D3D device, window handle, scene pointer, player data, input state, difficulty settings,
tournament data, and rendering context. This is the single most important global in the game.
Passed toApp_Initialize_Full(),App_Run(), andApp_Shutdown()from WinMain. - Used in:
WinMain+0x0A(DATA) —App_Initialize_Full(&g_App, ...)WinMain+0x14(DATA) —App_Run((int*)&g_App)WinMain+0x1E(DATA) —App_Shutdown((int*)&g_App)FUN_004ce4e0(DATA) —LoadOrSaveConfig(&g_App.dwVtable)Unwind@004ce3e6+0x1A(DATA) — exception handler cleanup
g_renderIndex
- Address:
0x005341CC - Type:
uint32(4 bytes, signed integer) - Description: Render frame counter / sprite animation index. Incremented once per
rendered object duringScene_RenderFrame. Used to index sprite animation ranges via
SpriteAnim_SetRange(). Reset to 0 at the start of each frame when the scene is active. - Used in:
Scene_RenderFrame+0x3F(WRITE) —g_renderIndex = 0;(reset per frame)Scene_RenderFrame+0x181(READ) —SpriteAnim_SetRange(param_1, g_renderIndex);Scene_RenderFrame+0x18E(READ_WRITE) —g_renderIndex = g_renderIndex + 1;
MeshWorld_vtable
- Address:
0x004D9CDC(.rdata) - Type:
pointer(4 bytes, pointer to vtable array) - Description: Virtual function table for the
MeshWorldclass. Assigned to all MeshWorld
instances during construction (*(undefined***)this = &MeshWorld_vtable;). Contains
virtual destructor and other MeshWorld virtual methods. - Used in:
MeshWorld_ctor+0x1A(DATA) —*this = &MeshWorld_vtable;MeshObject_dtor+0x1F(DATA) — vtable lookup during destruction
PTR_OBJ_VTABLE
- Address:
0x004F7360(.data) - Type:
pointer(4 bytes, function pointer table) - Description: Pointer to the base engine object vtable (at
0x004D8F88in .rdata).
Initialized at startup byFUN_004ce500. Used extensively (193 xrefs) for:- RNG operations (
RNG_Rand(&PTR_OBJ_VTABLE, ...)) — contains the random number generator state - Object construction/destruction (sets vtable on new objects)
- Scene camera setup, ball spawning, collision setup
- Board level constructors, rumble board setup
- Sound channel initialization
- RNG operations (
- Initialized by:
FUN_004ce500→PTR_OBJ_VTABLE = &PTR_LAB_004d8f88; - Used in (193 locations, key ones):
Ball_ctor2+0x126—RNG_Rand(&PTR_OBJ_VTABLE, 3, '\0')for random ball propertiesBall_Update+0xFF— per-frame ball physicsBall_Shatter+0x21F— ball splitting mechanicScene_SetCamera+0x1EA— camera setupScene_SpawnBallsAndObjects+0x174— level object spawningScene_UpdateBallsAndState+0x3E4— main game logic updateScene_ComputeArenaLighting+0x202— arena lightingBoard_Master_Update+0x42— master board updateCreateBumper+0x7F3,HandleArenaCollisionEvents+0x3C0,DispatchCollisionEvents+0x7AC— object creationArenaBoard_Update+0x331— arena mode updateSound_InitChannels+0x90— audio system initLoadingScreenGadget_Ctor+0x2E2— loading screenScoreDisplay_SetTime+0x09— score display- Many level dtor functions (LevelBoard_Dizzy_dtor, LevelBoard_Tower_dtor, etc.)
PTR_PTR_004f7188
- Address:
0x004F7188(.data) - Type:
pointer(4 bytes, pointer to math lookup table) - Description: Pointer to the engine's trigonometric lookup table (at
0x004D8E5Cin .rdata).
Used as the first argument toMath_Atan2Angle(),Wave_Sin(), andWave_Cos()for fast
angle and wave computations. Contains pre-computed sine/cosine/atan2 tables for
performance (avoids FPU transcendental calls). 121 xrefs across physics, rendering, and camera. - Initialized by:
FUN_004ce4f0→PTR_PTR_004f7188 = &PTR_LAB_004d8e5c; - Used in (121 locations, key ones):
Ball_ApplyForceV2+0x12F—Math_Atan2Angle(&PTR_PTR_004f7188, x, z, 0, 0)for movement directionBall_ApplyForceWithMultipliers+0x12F— same with force multipliersBall_FallUpdate+0x44F— falling physicsBall_Update+0x1F95— main ball physics tickScene_SetCamera+0x111—Wave_Sin(&PTR_PTR_004f7188, ...)for camera orbitScene_RenderWithCamera+0xBF— render camera transformScene_ComputeLighting+0xA5— lighting calculationSprite_DrawRotatedQuad+0x152— sprite rotationGear_AdvanceAlongPath+0x49A— gear path followingFlagWaver_UpdateVertices+0x48— flag wave effect
PTR_PTR_004f7448
- Address:
0x004F7448(.data) - Type:
pointer(4 bytes, pointer to string format table) - Description: Pointer to a level/texture name format string table. Used by
AthenaString_Format(0x4f7448, &DAT_004d03f8)to generate texture filenames for levels.
Initialized to&PTR_SoundBuffer_ScalarDtor_004d9124byFUN_004ce510(static init).
99 xrefs — primarily in UI/menu constructors and render functions for texture loading. - Initialized by:
FUN_004ce510→PTR_PTR_004f7448 = &PTR_SoundBuffer_ScalarDtor_004d9124; - Used in (99 locations, key ones):
TimeTrialMenu_ctor+0x6E—AthenaString_Format(0x4f7448, ...)for level texturesHighScoreMenu_Render+0x291— high score display texturesArenaBoard_Render+0x218— arena renderingDifficultyMenu_Render+0x252— difficulty selectionRaceGoalReached_Tick+0x297— race completionScene_SetupLevel10+0xBB— Master level setupScene_SetupLevelCascade+0xCA— Beginner Race setupTourneyMenu_ctor+0x26F— tournament menu constructionMPMenu_ctor+0x356— multiplayer menuOptions_Menu_dtor+0x1C8— options cleanupBall_Update+0x1219— ball rendering
PTR_PTR_004f77c0
- Address:
0x004F77C0(.data) - Type:
pointer(4 bytes, pointer to diagnostic data) - Description: Pointer to a diagnostic data structure used by
App_BuildDiagnosticReport.
Initialized to&PTR_LAB_004da74cbyFUN_004ce550. Used when building crash/error reports.
Contains system information for the diagnostic report. - Initialized by:
FUN_004ce550→PTR_PTR_004f77c0 = &PTR_LAB_004da74c; - Used in:
App_BuildDiagnosticReport+0x4EF(DATA)App_BuildDiagnosticReport+0x56C(DATA)App_BuildDiagnosticReport+0x5E9(DATA)App_BuildDiagnosticReport+0x666(DATA)App_BuildDiagnosticReport+0x6E3(DATA)
s_BACK
- Address:
0x004D2334(.rdata) - Type:
char[5](string literal "BACK\0", Ghidra labels asundefined1) - Description: The string literal
"BACK". Used as the label for the 'Back' / 'Previous Menu'
button across all menus and UI screens. 45 xrefs — appears in every menu constructor and handler. - Used in (45 locations, key ones):
ArenaMenu_ctor+0x6BD— arena selection back buttonTourneyMenu_ctor+0x2C2— tournament menu back buttonKeyRemapMenu_Ctor+0x1E5— key remapping back buttonConfirmMenu_ctor+0x7A— confirmation dialog back buttonCreditsScreen_ctor+0x2B7— credits screen back buttonPracticeMenu_ctor+0xAF4— practice level selection back buttonOptionsMenu_ctor+0x312— options menu back buttonPauseMenu_Ctor+0xD6— pause menu back buttonDifficultyMenu_ctor+0x10B— difficulty menu back buttonPauseMenu_HandleButtonClick+0xB2— back button click handlerOptionsMenu_HandleButtonClick+0x481— options back click handlerQuitRace+0x10B— quit race back button
2. D3D / Graphics Function Pointer Globals
These are function pointers set up at runtime for graphics operations. The D3D thunk system
uses indirect calls through these pointers to support multiple code paths (e.g., MMX vs scalar).
PTR_D3DX_ShaderDispatch0
- Address:
0x004F7194 - Type:
pointer(4 bytes, function pointer) - Description: Function pointer for D3DX shader dispatch (shader profile 0). Set by
D3DX_DetectShaderProfile()based on CPU capabilities. Called indirectly to execute
vertex/pixel shader operations. - Used in:
D3DX_DetectShaderProfile+0x22(WRITE) — assignmentD3DX_DetectShaderProfile+0x51(WRITE) — assignmentD3DX_ShaderDispatch0+0x0(INDIRECTION) — indirect callD3DX_ShaderDispatch0+0x7(INDIRECTION) — indirect call
PTR_D3DX_ShaderDispatch1
- Address:
0x004F71B8 - Type:
pointer(4 bytes, function pointer) - Description: Function pointer for D3DX shader dispatch profile 1.
- Used in:
D3DX_ShaderDispatch1(2 refs)
PTR_D3DX_ShaderDispatch2
- Address:
0x004F71CC - Type:
pointer(4 bytes, function pointer) - Description: Function pointer for D3DX shader dispatch profile 2.
- Used in:
D3DX_ShaderDispatch2(2 refs)
PTR_D3DX_ShaderDispatch_noarg
- Address:
0x004F71FC - Type:
pointer(4 bytes, function pointer) - Description: No-argument variant of D3DX shader dispatch.
- Used in: shader dispatch functions (2 refs)
PTR_D3DX_ShaderDispatch_noarg2
- Address:
0x004F71F4 - Type:
pointer(4 bytes, function pointer) - Description: Second no-argument variant of D3DX shader dispatch.
- Used in: shader dispatch functions (2 refs)
PTR_D3DX_ShaderDispatch_noarg4
- Address:
0x004F721C - Type:
pointer(4 bytes, function pointer) - Description: Fourth no-argument variant of D3DX shader dispatch.
- Used in: shader dispatch functions (2 refs)
PTR_D3DX_ShaderDispatch_noarg5
- Address:
0x004F71E8 - Type:
pointer(4 bytes, function pointer) - Description: Fifth no-argument variant of D3DX shader dispatch.
- Used in: shader dispatch functions (2 refs)
PTR_D3DX_ShaderDispatch_2a / 2b / 2c
- Addresses:
0x004F7208/0x004F720C/0x004F7210 - Type:
pointer(4 bytes each, function pointers) - Description: D3DX shader dispatch variants 2a, 2b, 2c. Different parameter configurations
for the shader execution pipeline. - Used in: respective ShaderDispatch_2a/2b/2c functions (2 refs each)
PTR_D3DX_ShaderDispatch_3
- Address:
0x004F7238 - Type:
pointer(4 bytes, function pointer) - Description: D3DX shader dispatch variant 3.
- Used in:
D3DX_ShaderDispatch_3(2 refs)
PTR_D3DX_ShaderDispatch_4
- Address:
0x004F7214 - Type:
pointer(4 bytes, function pointer) - Description: D3DX shader dispatch variant 4.
- Used in:
D3DX_ShaderDispatch_4(2 refs)
PTR_D3DX_ShaderDispatch_4b
- Address:
0x004F71F8 - Type:
pointer(4 bytes, function pointer) - Description: D3DX shader dispatch variant 4b (alternative parameter format).
- Used in: shader dispatch functions (2 refs)
PTR_Graphics_SetRenderState
- Address:
0x004F719C - Type:
pointer(4 bytes, function pointer) - Description: Function pointer for
Graphics_SetRenderState. Called indirectly via
(*(code*)PTR_Graphics_SetRenderState_004f719c)();to set D3D render states. - Used in:
Graphics_SetRenderState(2 refs)
PTR_Graphics_InitShaderDispatch
- Address:
0x004F71AC - Type:
pointer(4 bytes, function pointer) - Description: Function pointer for graphics shader dispatch initialization.
- Used in:
Graphics_InitShaderDispatch(2 refs)
PTR_Matrix_TransformVec4x3
- Address:
0x004F7278 - Type:
pointer(4 bytes, function pointer) - Description: Function pointer for 4x3 matrix vector transformation. Used by
D3DX_DetectShaderProfile()to select between CPU-specific implementations. - Used in:
D3DX_DetectShaderProfile(4 refs — read and indirection)
PTR_D3D_Thunk0 through PTR_D3D_Thunk_C
- Addresses:
0x004F722C(Thunk0),0x004F7230(Thunk4),0x004F7234(Thunk5),
0x004F71D4(Thunk_6),0x004F724C(Thunk_7),0x004F7250(Thunk_8),
0x004F7258(Thunk_9),0x004F725C(Thunk_A),0x004F7264(Thunk_B),
0x004F7268(Thunk_C) - Type:
pointer(4 bytes each, function pointers) - Description: D3D device thunk function pointers. Each thunk detects the shader profile
(D3DX_DetectShaderProfile) then calls through the stored pointer. Used to dispatch D3D
device operations through the shader-accelerated code path. Each has 1 xref.
PTR_D3DX_Thunk3Param
- Address:
0x004F7204 - Type:
pointer(4 bytes, function pointer) - Description: D3DX thunk for 3-parameter operations.
- Used in: 1 xref (thunk dispatch)
PTR_D3DThunk_DrawIndexedPrimitiveUP
- Address:
0x004F723C - Type:
pointer(4 bytes, function pointer) - Description: Function pointer for
DrawIndexedPrimitiveUPD3D device operation.
Dispatches indexed primitive drawing through the shader pipeline. - Used in: 1 xref (thunk dispatch)
PTR_D3DX_BoxFilter2x_Init / PTR_D3DX_BoxFilter2x_Init16
- Addresses:
0x004FA638/0x004FA63C - Type:
pointer(4 bytes each, function pointers) - Description: Function pointers for 2x box filter operations (texture downsampling).
Set byD3DX_BoxFilter2x_Init()based on MMX availability:- No MMX:
D3DX_BoxFilter2x_Scalar/D3DX_BoxFilter2x_NoMMX - With MMX:
D3DX_BoxFilter2x_MMX/D3DX_BoxFilter2x_MMX
- No MMX:
- Used in:
D3DX_BoxFilter2x_Init+0x11/0x1D/0x32(WRITE) — sets the function pointerD3DX_BoxFilter2x_Init16+0x11/0x18/0x27(WRITE) — sets the 16-bit variantD3DX_BoxFilter2x_Dispatch+0x26(READ) — calls through pointerD3DX_BoxFilter2x_Dispatch16+0x26(READ) — calls through pointer
PTR_D3DDevice_Reset (two entries)
- Addresses:
0x004DB360/0x004DB3D0(.rdata) - Type:
pointer(4 bytes each) - Description: D3D device vtable entries for the
Resetmethod. These are entries
in the COM vtable for the D3D9 device interface, used byD3D_GetAdapterMode. - Used in:
D3D_GetAdapterMode(2 refs each)
PTR_COM_QueryInterface
- Address:
0x004DBEAC(.rdata) - Type:
pointer(4 bytes) - Description: COM
QueryInterfacevtable entry. Used byCOM_QueryInterface()to
query D3D COM interfaces for specific GUIDs. - Used in:
COM_QueryInterface(2 refs)
3. Resource Pool & Audio Globals
PTR_PTR_004fa7d8
- Address:
0x004FA7D8 - Type:
pointer(4 bytes, pointer to vtable array) - Description: Pointer to a vtable array for D3D resource pool objects. Used by
D3DResourcePool_Release()to call virtual destructors on pooled D3D resources.
Indexed by resource type (puVar2[-0x40]). Also used by the Vorbis decoder. - Used in:
D3DResourcePool_Release+0x58(DATA) —(**(code**)((&PTR_PTR_004fa7d8)[type] + 8))(obj);Vorbis_InitDecodeChannel+0x175(DATA)Vorbis_FreeDecoder+0x97(DATA)Vorbis_ReadSetupHeader+0x1A6(DATA)Vorbis_DecodeAudioFrame+0x139(DATA)
PTR_PTR_004fa7c4 / PTR_PTR_004fa7cc
- Addresses:
0x004FA7C4/0x004FA7CC - Type:
pointer(4 bytes each) - Description: Additional vtable arrays for D3D resource pool objects. Used alongside
PTR_PTR_004fa7d8byD3DResourcePool_Release()to dispatch virtual destructors for
different resource types. Also used by Vorbis decoder and channel freeing. - Used in:
D3DResourcePool_Release+0x88/+0xB8(DATA)Vorbis_ReadSetupHeader+0xDD/+0x146(DATA)Channel_FreePairArray+0x11C/+0x15C(DATA)
4. Game Content / Level Data Globals
PTR_s_WARM-UP_RACE_004f7080
- Address:
0x004F7080 - Type:
pointer(4 bytes, pointer to string array) - Description: Pointer to an array of race level name strings. Indexed by level number
inTourneyMenu_GetRaceName:return (&PTR_s_WARM_UP_RACE_004f7080)[level_index];
Contains strings like "WARM-UP RACE", "BEGINNER RACE", etc. - Used in:
TourneyMenu_GetRaceName+0x03(1 xref)
PTR_s_TAKE_YOUR_TIME_ON_THE_WARM-UP_RA_004f7148
- Address:
0x004F7148 - Type:
pointer(4 bytes, pointer to string array) - Description: Pointer to an array of level description/tutorial strings. Contains
helpful text like "TAKE YOUR TIME ON THE WARM-UP RACE" displayed when selecting levels. - Used in: 1 xref (level selection display)
PTR_s_HAMSTER_PELLET_004f70c8
- Address:
0x004F70C8 - Type:
pointer(4 bytes, pointer to string) - Description: Pointer to the string "HAMSTER PELLET". Used in tournament/menu rendering
for displaying hamster pellet-related UI elements (likely score or item display). - Used in:
TourneyMenu_Render+0xB07(DATA)ConfirmMenu_Render+0x75E(DATA)TourneyMenu_TickWithRank+0x11F2(DATA)HighScoreEntry_DeletingDtor+0x69E(DATA)
PTR_Rsrc_DLL_1_409[304949]
- Address:
0x004D5E88(.rdata) - Type:
pointer(4 bytes, pointer to resource data) - Description: Pointer to embedded DLL resource data (resource ID 304949). Used by
OptionsMenu_ApplySettingsfor applying graphics/display settings. The embedded DLL
is likely the eSellerate licensing engine or a D3D helper. - Used in:
OptionsMenu_ApplySettings+0x2B7/+0x30F/+0x496/+0x4EE(DATA)
PTR_RaceGoalReached_Render (two entries)
- Addresses:
0x004D6C70/0x004D6CB8(.rdata) - Type:
pointer(4 bytes each) - Description: Vtable pointers for the
ScoreObjectclass (race goal objects).
Assigned inScoreObject_ctor:*this = &PTR_RaceGoalReached_Render_004d6c70;
Contains virtual render function for race finish/goal markers. - Used in:
ScoreObject_ctor+0x??(1 xref each) — vtable assignment
PTR_Level_LoadCollision
- Address:
0x004D90C8(.rdata) - Type:
pointer(4 bytes, function pointer) - Description: Function pointer to
Level_LoadCollision. Stored in a vtable for
indirect calling during level collision mesh setup. - Used in:
Level_LoadCollision(1 xref)
PTR_SceneObject_SetVisible
- Address:
0x004D935C(.rdata) - Type:
pointer(4 bytes, function pointer) - Description: Function pointer to
SceneObject_SetVisible. Stored in a vtable for
indirect calling when toggling object visibility. - Used in:
SceneObject_SetVisible(1 xref)
5. CRT / Runtime Globals
These are MSVC C Runtime (CRT) global variables used for CRT initialization,
exception handling, and locale management.
PTR_PTR_004fc664
- Address:
0x004FC664 - Type:
pointer(4 bytes, pointer tolconvstructure) - Description: Pointer to the CRT locale conversion (
lconv) structure. Used by
___free_lconv_numand___free_lconv_monto compare locale string pointers before
freeing — only frees strings that are NOT the static defaults from thelconvstruct.
Also used byCRT_GetLocalePtr. - Used in:
___free_lconv_num+0x0B/+0x27(READ)___free_lconv_mon+0x10/+0x2D/+0x4A/+0x67/+0x84/+0xA1/+0xBE(READ)CRT_GetLocalePtr+0x00(READ)
PTR_PTR_004fc6bc
- Address:
0x004FC6BC - Type:
pointer(4 bytes) - Description: Additional locale structure pointer. Used by
___updatetlocinfo
(CRT thread locale info update). - Used in:
___updatetlocinfo(1 xref)
PTR_s_R6009_-_not_enough_space_for_env_004fc85c
- Address:
0x004FC85C - Type:
pointer(4 bytes, pointer to error string) - Description: Pointer to the CRT runtime error message string for error R6009
("not enough space for environment"). Used byCRT_RuntimeErrorto display the
appropriate error message when the runtime encounters an out-of-memory condition. - Used in:
CRT_RuntimeError+0xDE/+0x11B/+0x141/+0x147/+0x150(READ/DATA)
PTR_s_(null)_004fc840
- Address:
0x004FC840 - Type:
pointer(4 bytes, pointer to string) - Description: Pointer to the string
"(null)". Used by CRT string formatting
functions as a fallback for NULL string pointers during printf/sprintf operations. - Used in: 2 xrefs in CRT string formatting
PTR_ReturnZero_004fce88
- Address:
0x004FCE88 - Type:
pointer(4 bytes, function pointer) - Description: Function pointer to a function that returns zero. Used by
FPU_WriteMathErroras a handler for FPU exception processing. Called as
iVar1 = (*(code*)PTR_ReturnZero_004fce88)(&local_28); - Used in:
FPU_WriteMathError+0x11D/+0x1D8/+0x26B(READ)
PTR_terminate_004fc82c
- Address:
0x004FC82C - Type:
pointer(4 bytes, function pointer) - Description: C++
std::terminatehandler function pointer. Used by the CRT
exception handling system (___InternalCxxFrameHandler) when an unhandled exception occurs. - Used in:
___InternalCxxFrameHandler(1 xref)
PTR_CRT_amsg_exit (6 entries)
- Addresses:
0x004FC810through0x004FC824 - Type:
pointer(4 bytes each) - Description: CRT abort message exit handlers. Array of function pointers for
different CRT abort error codes. Called when the CRT needs to terminate with a specific error. - Used in: 2 xrefs each (CRT abort dispatch)
PTR_CRT_InitSecurityCookie_004f7004
- Address:
0x004F7004 - Type:
pointer(4 bytes, function pointer) - Description: CRT security cookie initialization function pointer. Part of the
MSVC stack buffer overrun protection mechanism. - Used in: 1 xref (CRT init)
PTR____onexitinit_004f7030
- Address:
0x004F7030 - Type:
pointer(4 bytes, function pointer) - Description: CRT
atexit/onexitinitialization function pointer. - Used in: 1 xref (CRT init)
PTR____endstdio_004f704c
- Address:
0x004F704C - Type:
pointer(4 bytes, function pointer) - Description: CRT stdio cleanup function pointer. Called during CRT shutdown
to flush and close standard I/O streams. - Used in: 1 xref (CRT shutdown)
PTR_CRT_SetUnhandledExceptionFilter_004f7058
- Address:
0x004F7058 - Type:
pointer(4 bytes, function pointer) - Description: CRT unhandled exception filter setup function pointer.
- Used in: 1 xref (CRT init)
PTR_CRT_InitFPState_004fc458
- Address:
0x004FC458 - Type:
pointer(4 bytes, function pointer) - Description: CRT floating-point unit state initialization function pointer.
- Used in: 1 xref (CRT init)
PTR___exit_004fc484
- Address:
0x004FC484 - Type:
pointer(4 bytes, function pointer) - Description: CRT exit function pointer.
- Used in: 1 xref (CRT shutdown)
s_0123456789ABCDEF_004f780c
- Address:
0x004F780C - Type:
char[17](string literal "0123456789ABCDEF\0") - Description: Hexadecimal digit lookup table (uppercase). Used by
CRT_FormatInteger
when format specifier is%X(uppercase hex). Each character's index = its hex value. - Used in:
CRT_FormatInteger+0x0F(DATA)CRT_FormatInteger+0x55(DATA)CRT_FormatInteger+0x7A(DATA)
s_0123456789abcdef_004f77f8
- Address:
0x004F77F8 - Type:
char[17](string literal "0123456789abcdef\0") - Description: Hexadecimal digit lookup table (lowercase). Used by
CRT_FormatInteger
when format specifier is%x(lowercase hex). - Used in:
CRT_FormatInteger+0x55(DATA)CRT_FormatInteger+0x7A(DATA)
s__004f76b8
- Address:
0x004F76B8 - Type:
byte[49](lookup table) - Description: Base32 decoding lookup table. Used by
Base32_Decodeto map
Base32 characters to their decoded values:bVar2 = s__004f76b8[*param_2];
Contains values 0x20 for invalid characters (whitespace). Part of the license key
validation system (LicenseKey_Validate). - Used in:
Base32_Decode+0x1E(DATA)Base32_Encode(1 xref)
6. Licensing / eSellerate Globals
These globals support the eSellerate DRM/licensing system embedded in the game.
s_\eSellerateEngine.dll_004f74b0
- Address:
0x004F74B0 - Type:
char[22](string literal) - Description: Filename string
"\eSellerateEngine.dll". Used by
eSellerate_ExtractDLLNullto locate and extract the eSellerate licensing DLL. - Used in:
eSellerate_ExtractDLL(1 xref)
s_Software\eSellerate\Affiliates\%_004f74cc
- Address:
0x004F74CC - Type:
char[37](string literal) - Description: Registry path template
"Software\eSellerate\Affiliates\%s".
Used for reading/writing eSellerate affiliate data in the Windows registry. - Used in:
eSellerate_ExtractDLL(1 xref)
s_RegCloseKey / s_RegQueryValueExA / s_RegOpenKeyExA / s_advapi32
- Addresses:
0x004F74F4/0x004F7500/0x004F7514/0x004F7524 - Type:
char[](string literals) - Description: Windows API function name strings used by
eSellerate_ExtractDLL
to dynamically load registry functions from advapi32.dll viaGetProcAddress.
Strings:"RegCloseKey","RegQueryValueExA","RegOpenKeyExA","advapi32". - Used in:
eSellerate_ExtractDLL(1 xref each)
s_MVESD_Entry2_004f77d4 / s_eSellerateEngine_004f77e4
- Addresses:
0x004F77D4/0x004F77E4 - Type:
char[](string literals) - Description: eSellerate engine entry point and module name strings.
"MVESD_Entry2"is the DLL export function name;"eSellerateEngine"is the module name. - Used in: 1 xref each (eSellerate init)
PTR_s_http://bugs.raptisoft.com / PTR_s_RaptisoftBugTracker
- Addresses:
0x004F77C4/0x004F77C8 - Type:
pointer(4 bytes each) - Description: Pointers to the Raptisoft bug tracker URL string and name.
Used when building diagnostic/crash reports to include the bug submission URL. - Used in: 1 xref each (diagnostic report)
7. RTTI / Exception Handling Globals
vftable @ 0x004e9b44
- Address:
0x004E9B44(.rdata) - Type:
pointer(4 bytes, RTTI vtable) - Description: RTTI type info vtable. Used by
TypeInfo_Dtorfor destroying
C++ type_info objects. Referenced by multiple RTTI Type Descriptor structures. - Used in:
TypeInfo_Dtor+0x5(DATA)- RTTI Type Descriptor entries at
0x004F7450,0x004F7468,0x004F7488,
0x004FC414,0x004FC434,0x004FC4A0
PTR_RTTI_Type_Descriptor entries
- Addresses:
0x004EE664,0x004EE67C,0x004EE6C4,0x004EE710(.rdata) - Type:
pointer(4 bytes each) - Description: RTTI Type Descriptor pointers. Used by the C++ runtime for
dynamic_castandtypeidoperations. Point to TypeDescriptor structures that
describe class type information. - Used in: 1-3 xrefs each (RTTI lookup)
PTR_PTR_004f7820
- Address:
0x004F7820 - Type:
pointer(4 bytes) - Description: Pointer to a CRT string table entry. Initialized to
&PTR_LAB_004db4dcbyFUN_004ce570. Used byAthenaString_AssignCRLF
for CRLF string assignment operations. - Initialized by:
FUN_004ce570→PTR_PTR_004f7820 = &PTR_LAB_004db4dc; - Used in:
AthenaString_AssignCRLF+0x??(1 xref)FUN_004ce570(WRITE, static init)
8. Miscellaneous Globals
DWORD_004f5b80
- Address:
0x004F5B80 - Type:
dword(4 bytes, signed integer) - Description: Referenced by
sub_004001a0(CRT startup code in the PE header region).
Likely a CRT initialization flag or security cookie value. - Used in:
sub_004001a0(1 xref)
PTR_s_Bogus_message_code_%d_004e42d8
- Address:
0x004E42D8(.rdata) - Type:
pointer(4 bytes) - Description: Pointer to format string
"Bogus message code %d". Used for
displaying unknown/invalid Windows message codes in the window procedure. - Used in: 1 xref (window message handler)
u_null)_004e9e86
- Address:
0x004E9E86(.rdata) - Type:
wchar16[6](Unicode string) - Description: Unicode string
"(null)". Used as a fallback for NULL wide-string
pointers in CRT wide-character formatting functions. - Used in: 1 xref (CRT wide string formatting)
Summary Table
| # | Name | Address | Type | Size | Xrefs | Category |
|---|---|---|---|---|---|---|
| 1 | PTR_OBJ_VTABLE | 0x004F7360 | pointer | 4 | 193 | Engine (RNG/object vtable) |
| 2 | PTR_PTR_004f7188 | 0x004F7188 | pointer | 4 | 121 | Engine (trig lookup table) |
| 3 | PTR_PTR_004f7448 | 0x004F7448 | pointer | 4 | 99 | Engine (texture format table) |
| 4 | s_BACK | 0x004D2334 | char[5] | 1* | 45 | UI (back button label) |
| 5 | PTR_PTR_004fc664 | 0x004FC664 | pointer | 4 | 11 | CRT (locale lconv) |
| 6 | PTR_PTR_004f77c0 | 0x004F77C0 | pointer | 4 | 6 | Engine (diagnostic data) |
| 7 | PTR_D3DX_BoxFilter2x_Init | 0x004FA638 | pointer | 4 | 6 | Graphics (box filter) |
| 8 | PTR_PTR_004fa7d8 | 0x004FA7D8 | pointer | 4 | 5 | Audio/Graphics (vtable array) |
| 9 | PTR_s_R6009 | 0x004FC85C | pointer | 4 | 5 | CRT (error string) |
| 10 | g_App | 0x004FD680 | App struct | 2328 | 5 | Engine (main app object) |
| 11 | PTR_s_HAMSTER_PELLET | 0x004F70C8 | pointer | 4 | 4 | UI (string pointer) |
| 12 | PTR_D3DX_ShaderDispatch0 | 0x004F7194 | pointer | 4 | 4 | Graphics (shader dispatch) |
| 13 | PTR_Matrix_TransformVec4x3 | 0x004F7278 | pointer | 4 | 4 | Graphics (matrix ops) |
| 14 | PTR_D3DX_BoxFilter2x_Init16 | 0x004FA63C | pointer | 4 | 4 | Graphics (box filter 16) |
| 15 | s_0123456789ABCDEF | 0x004F780C | char[17] | 17 | 3 | CRT (hex lookup) |
| 16 | PTR_PTR_004fa7c4 | 0x004FA7C4 | pointer | 4 | 3 | Audio (vtable array) |
| 17 | PTR_PTR_004fa7cc | 0x004FA7CC | pointer | 4 | 3 | Audio (vtable array) |
| 18 | PTR_ReturnZero | 0x004FCE88 | pointer | 4 | 3 | CRT (FPU error handler) |
| 19 | g_renderIndex | 0x005341CC | uint32 | 4 | 3 | Engine (render counter) |
| 20 | PTR_Graphics_SetRenderState | 0x004F719C | pointer | 4 | 2 | Graphics (render state) |
| 21 | PTR_Graphics_InitShaderDispatch | 0x004F71AC | pointer | 4 | 2 | Graphics (shader init) |
| 22 | PTR_D3DX_ShaderDispatch1 | 0x004F71B8 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 23 | PTR_D3DX_ShaderDispatch2 | 0x004F71CC | pointer | 4 | 2 | Graphics (shader dispatch) |
| 24 | PTR_D3DX_ShaderDispatch_noarg5 | 0x004F71E8 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 25 | PTR_D3DX_ShaderDispatch_noarg2 | 0x004F71F4 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 26 | PTR_D3DX_ShaderDispatch_4b | 0x004F71F8 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 27 | PTR_D3DX_ShaderDispatch_noarg | 0x004F71FC | pointer | 4 | 2 | Graphics (shader dispatch) |
| 28 | PTR_D3DX_ShaderDispatch_2a | 0x004F7208 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 29 | PTR_D3DX_ShaderDispatch_2b | 0x004F720C | pointer | 4 | 2 | Graphics (shader dispatch) |
| 30 | PTR_D3DX_ShaderDispatch_2c | 0x004F7210 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 31 | PTR_D3DX_ShaderDispatch_4 | 0x004F7214 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 32 | PTR_D3DX_ShaderDispatch_noarg4 | 0x004F721C | pointer | 4 | 2 | Graphics (shader dispatch) |
| 33 | PTR_D3DX_ShaderDispatch_3 | 0x004F7238 | pointer | 4 | 2 | Graphics (shader dispatch) |
| 34 | s__004f76b8 | 0x004F76B8 | byte[49] | 49 | 2 | Licensing (Base32 table) |
| 35 | s_0123456789abcdef | 0x004F77F8 | char[17] | 17 | 2 | CRT (hex lookup) |
| 36 | PTR_PTR_004f7820 | 0x004F7820 | pointer | 4 | 2 | CRT (string table) |
| 37 | PTR_s_(null) | 0x004FC840 | pointer | 4 | 2 | CRT (null string fallback) |
| 38 | PTR_s_WARM-UP_RACE | 0x004F7080 | pointer | 4 | 1 | Game (level names) |
| 39 | PTR_s_TAKE_YOUR_TIME | 0x004F7148 | pointer | 4 | 1 | Game (level descriptions) |
| 40 | PTR_D3D_Thunk_6 | 0x004F71D4 | pointer | 4 | 1 | Graphics (D3D thunk) |
| 41 | PTR_D3DX_Thunk3Param | 0x004F7204 | pointer | 4 | 1 | Graphics (D3D thunk) |
| 42 | PTR_D3D_Thunk0 | 0x004F722C | pointer | 4 | 1 | Graphics (D3D thunk) |
| 43 | PTR_D3D_Thunk4 | 0x004F7230 | pointer | 4 | 1 | Graphics (D3D thunk) |
| 44 | PTR_D3D_Thunk5 | 0x004F7234 | pointer | 4 | 1 | Graphics (D3D thunk) |
| 45 | PTR_D3DThunk_DrawIndexedPrimUP | 0x004F723C | pointer | 4 | 1 | Graphics (D3D thunk) |
| 46 | PTR_D3D_Thunk_7 through _C | 0x4F724C-4F7268 | pointer | 4 each | 1 each | Graphics (D3D thunks) |
| 47 | s_\eSellerateEngine.dll | 0x004F74B0 | char[22] | 22 | 1 | Licensing (DLL name) |
| 48 | s_Software\eSellerate... | 0x004F74CC | char[37] | 37 | 1 | Licensing (reg path) |
| 49 | s_RegCloseKey | 0x004F74F4 | char[12] | 12 | 1 | Licensing (API name) |
| 50 | s_RegQueryValueExA | 0x004F7500 | char[17] | 17 | 1 | Licensing (API name) |
| 51 | s_RegOpenKeyExA | 0x004F7514 | char[14] | 14 | 1 | Licensing (API name) |
| 52 | s_advapi32 | 0x004F7524 | char[9] | 9 | 1 | Licensing (DLL name) |
| 53 | PTR_s_http://bugs.raptisoft | 0x004F77C4 | pointer | 4 | 1 | Misc (bug tracker URL) |
| 54 | PTR_s_RaptisoftBugTracker | 0x004F77C8 | pointer | 4 | 1 | Misc (bug tracker name) |
| 55 | s_MVESD_Entry2 | 0x004F77D4 | char[13] | 13 | 1 | Licensing (entry point) |
| 56 | s_eSellerateEngine | 0x004F77E4 | char[17] | 17 | 1 | Licensing (module name) |
| 57 | PTR_PTR_004fc6bc | 0x004FC6BC | pointer | 4 | 1 | CRT (locale update) |
| 58 | PTR_terminate | 0x004FC82C | pointer | 4 | 1 | CRT (std::terminate) |
| 59 | MeshWorld_vtable | 0x004D9CDC | pointer | 4 | 2 | Engine (MeshWorld vtable) |
| 60 | vftable | 0x004E9B44 | pointer | 4 | 7 | RTTI (type info vtable) |
| 61 | PTR_Rsrc_DLL_1_409 | 0x004D5E88 | pointer | 4 | 4 | Resource (embedded DLL) |
| 62 | PTR_RaceGoalReached_Render (x2) | 0x4D6C70/4D6CB8 | pointer | 4 each | 1 each | Game (ScoreObject vtable) |
| 63 | PTR_Level_LoadCollision | 0x004D90C8 | pointer | 4 | 1 | Game (collision func ptr) |
| 64 | PTR_SceneObject_SetVisible | 0x004D935C | pointer | 4 | 1 | Game (visibility func ptr) |
| 65 | PTR_D3DDevice_Reset (x2) | 0x4DB360/4DB3D0 | pointer | 4 each | 2 each | Graphics (D3D vtable) |
| 66 | PTR_COM_QueryInterface | 0x004DBEAC | pointer | 4 | 2 | Graphics (COM vtable) |
| 67 | DWORD_004f5b80 | 0x004F5B80 | dword | 4 | 1 | CRT (init flag) |
| 68 | PTR_s_Bogus_message_code | 0x004E42D8 | pointer | 4 | 1 | Misc (error string) |
| 69 | u_null) | 0x004E9E86 | wchar16[6] | 12 | 1 | CRT (unicode null) |
| 70 | PTR_CRT_InitSecurityCookie | 0x004F7004 | pointer | 4 | 1 | CRT (security cookie) |
| 71 | PTR____onexitinit | 0x004F7030 | pointer | 4 | 1 | CRT (atexit init) |
| 72 | PTR____endstdio | 0x004F704C | pointer | 4 | 1 | CRT (stdio cleanup) |
| 73 | PTR_CRT_SetUnhandledExFilter | 0x004F7058 | pointer | 4 | 1 | CRT (exception filter) |
| 74 | PTR_CRT_InitFPState | 0x004FC458 | pointer | 4 | 1 | CRT (FPU init) |
| 75 | PTR___exit | 0x004FC484 | pointer | 4 | 1 | CRT (exit) |
| 76 | PTR_CRT_amsg_exit (x6) | 0x4FC810-4FC824 | pointer | 4 each | 1-2 each | CRT (abort handlers) |
| 77 | PTR_RTTI_Type_Descriptor (x4) | 0x4EE664-4EE710 | pointer | 4 each | 1-3 each | RTTI (type descriptors) |
*s_BACK is labeled as 1 byte by Ghidra but is actually a 5-byte string ("BACK\0").
Excluded from this document
The following categories of global data were excluded as they are not game-specific:
- TEB (Thread Environment Block): ~70 entries at
0xFFDFF000-0xFFDFFFFF(system TEB fields) - IAT (Import Address Table): ~177 entries at
0x004CF000-0x004CF2FF(Windows API import thunks) - String constants: ~1666 string literals in
.rdata(level names, object names, error messages, etc.) - Vtable destructor pointers: ~218
PTR_*_Dtorentries in.rdata(C++ virtual destructor function pointers) - FuncInfo/UnwindMapEntry: ~400+ entries (MSVC exception handling tables)
- Resource data: Rsrc_ entries (icons, cursors, embedded DLL data)
- TypeDescriptor (RTTI): 6 entries in
.data(C++ RTTI type descriptors)
🔗 Related Documents
Global Windmill Mod
Global Windmill Mod
Spawns Tower Race windmills on any level with a hotkey. Creates BOTH a visual mesh AND a collision level — the ball gets spun (N:SWIRL) when it touches the windmill.
Usage
- Load
GlobalWindmill.CEAin Cheat Engine - Enable the script
- Set
SpawnWindmillto 1 in CE (or use a hotkey) - Windmill spawns at player 1's position (both visual + collision)
How It Works
This mod creates two objects per spawn:
Part A: Visual Mesh (for rendering)
operator_new(0x10D0)→Stands_ctor(0x462850) with windmill mesh- Sets vtable to 0x4D8FB0 (renderable mesh)
- Position at
obj+0x10D4/+0x10D8/+0x10DC - Registered to
board+0x2578(general list) +board+0xCD4+ scene tree
Part B: CollisionLevel (for N:SWIRL ball spinning)
operator_new(0x10D0)→CollisionLevel_ctorWithLevel(0x465080)Level_LoadMeshes(0x465200) loads collision data- Position at
coll+0x10D8/+0x10DC/+0x10E0 - Registered to
board+0x2578+board+0xCD4+ scene tree - Registered with scene manager via
SceneObject_SetupCallback(0x4F7360, 0x168, 0) - N:SWIRL events baked into mesh → fire automatically when ball touches
Mesh Loading
- On first spawn: loads
Levels\Level4-Windmillmesh viaMeshWorld_ctor - Stores at
board+0x437C(same slot as Tower ctor) - On Tower Race, mesh is already loaded — reuses it
How N:SWIRL Works
The windmill mesh's collision triangles have N:SWIRL event names embedded in them. When the ball touches them:
Rotator_AddBall(board, ball); // 0x43B6F0
This applies a rotation matrix to the ball's position and velocity, spinning it around the windmill center. A 10-frame tick counter resets each frame the ball stays on the rotator.
Object Details
| Component | Alloc | Constructor | Vtable |
|---|---|---|---|
| Visual | 0x10D0 | Stands_ctor (0x462850) | 0x4D8FB0 |
| Collision | 0x10D0 | CollisionLevel_ctorWithLevel (0x465080) | N/A (collision-only) |
Full Analysis
See docs/WINDMILL_JUDGE_SYSTEM.md for complete reverse-engineering documentation.
🔗 Related Documents
Global Wobbly Race Object Spaw
Global Wobbly Race Object Spawner — WAVY1, WOBBLY1-4
Spawns Wobbly Race objects at Player 1's position in any level.
Usage
- Set
ObjectType(1-5):- 1 = WAVY1 (wavy floor — wave-based physics surface)
- 2 = WOBBLY1 (wobbly platform 1)
- 3 = WOBBLY2 (wobbly platform 2)
- 4 = WOBBLY3 (wobbly platform 3)
- 5 = WOBBLY4 (wobbly platform 4)
- Set
SpawnObjectto 1 to spawn at player position
How Each Object Works
WAVY1 (Wavy Floor)
The actual wavy floor from Wobbly Race — a large physics surface that creates wave-based deformation.
- Constructor:
Stands_CtorWithCollisionLevel(0x43AD40), alloc 0x1AE7C (110,204 bytes — huge!) - RET: 0x14 (5 params: board, X, Y, Z, mesh_path_string)
- Mesh:
"Levels\Level7-Wavy1"(string at 0x4CFC54) - vtable: 0x4D5458
- Update (vtable[0x0B/+0x2C] = 0x440390,
Scene_UpdateArenaPhysics):- Increments counter at +0x10F8 each frame
- When counter > 2: begins wave physics
- Uses
Wave_Sinfor wave motion deformation across multiple segments - Calls
Mesh_FindClosestCollisionfor ball collision - Transforms ball positions riding on the surface
- No path access, no external dependencies → safe for global spawn
Key difference from WOBBLY: Takes a mesh path string (not MeshWorld pointer). The constructor internally calls MeshWorld_ctor(this, device, path) to create its own mesh. No need to create a separate MeshWorld.
WOBBLY1-4 (Wobbly Platforms)
Wobbly platforms that create a wave-like motion for balls riding on them.
- Constructor:
GameLevel_ctor(0x4351F0), alloc 0x1524 - RET: 0x14 (5 params: board, X, Y, Z, mesh_pointer)
- vtable: 0x4D53F8 (Spinner vtable)
- Update (vtable[0x0B/+0x2C] = 0x43A700,
Stands_Update):- Reads wave amplitudes at +0x10F0/+0x10F4
- Reads position at +0x10D8/+0x10DC/+0x10E0
- Reads wave scale at +0x1100 (=150.0)
- Reads max amplitude at +0x10E8/+0x10EC (=10.0)
- Iterates balls on platform, transforms positions (wave motion)
- Uses
Gfx_ScaleY/Zfor deformation - No path access, no crash risks → safe for global spawn
Key difference from WAVY1: Takes a MeshWorld pointer (not path string). Must create a MeshWorld first via MeshWorld_ctor, then pass the pointer.
Factory Chain
LevelBoard_Wobbly_ctor (0x41F110):
LoadRaceData("WOBBLYRACE")
board+0x436C = MeshWorld("Levels\Level7-Wobbly1")
board+0x4370 = MeshWorld("Levels\Level7-Wobbly2")
board+0x4374 = MeshWorld("Levels\Level7-Wobbly3")
board+0x4378 = MeshWorld("Levels\Level7-Wobbly4")
board+0x437C = MeshWorld("Levels\Level7-Wobbly5")
board+0x4380 = MeshWorld("Levels\Level7-Wobbly6")
board+0x4384 = MeshWorld("Levels\Level7-Wobbly7")
Factory Dispatch (0x40F420, vtable[0x21]/+0x84):
"WOBBLY1" → GameLevel_ctor, mesh=board+0x436C
"WOBBLY2" → GameLevel_ctor, mesh=board+0x4370
"WAVY1" → Stands_CtorWithCollisionLevel, mesh_path="Levels\Level7-Wavy1"
"WOBBLY3" → GameLevel_ctor, mesh=board+0x4374
"WOBBLY4" → GameLevel_ctor, mesh=board+0x4378
Fallback → CreatePlatformOrStands (PLATFORM, STANDS)
Object Field Layout
WAVY1 — vtable 0x4D5458
| Offset | Type | Description |
|---|---|---|
| +0x000 | int* | vtable (0x4D5458) |
| +0x10D0 | int | board pointer |
| +0x10D4 | void* | CollisionLevel pointer |
| +0x10E0 | float | position X |
| +0x10E4 | float | position Y |
| +0x10E8 | float | position Z |
| +0x10D8 | int | 0 (unused?) |
| +0x10F8 | int | frame counter (increments, wave physics starts at >2) |
| +0x1104 | array | Vec3List array (0x32 entries × 0x418 bytes) |
| +0xDDB4 | array | Vec3List array (0x32 entries × 0x418 bytes) |
| +0x1AA64 | AthenaList | ball list |
WOBBLY1-4 — vtable 0x4D53F8
| Offset | Type | Description |
|---|---|---|
| +0x000 | int* | vtable (0x4D53F8) |
| +0x10D0 | int | board pointer |
| +0x10D4 | void* | CollisionLevel pointer |
| +0x10D8 | float | position X |
| +0x10DC | float | position Y |
| +0x10E0 | float | position Z |
| +0x10E4 | byte | flag (1 = active) |
| +0x10E8 | float | max amplitude X (10.0) |
| +0x10EC | float | max amplitude Z (10.0) |
| +0x10F0 | float | wave amplitude X |
| +0x10F4 | float | wave amplitude Z |
| +0x1100 | float | wave scale (150.0) |
| +0x1104 | byte | freeze X flag |
| +0x1105 | byte | freeze Z flag |
| +0x1108 | AthenaList | ball list |
| +0x1520 | int | sound channel |
Mesh String Addresses
| Address | String |
|---|---|
| 0x4D0CF4 | Levels\Level7-Wobbly1 |
| 0x4D0CDC | Levels\Level7-Wobbly2 |
| 0x4D0CC4 | Levels\Level7-Wobbly3 |
| 0x4D0CAC | Levels\Level7-Wobbly4 |
| 0x4CFC54 | Levels\Level7-Wavy1 |
String Table (0x4CFC40+)
| Address | String |
|---|---|
| 0x4CFC44 | WOBBLY4 |
| 0x4CFC4C | WOBBLY3 |
| 0x4CFC54 | Levels\Level7-Wavy1 |
| 0x4CFC68 | WAVY1 |
| 0x4CFC70 | WOBBLY2 |
| 0x4CFC78 | WOBBLY1 |
| 0x4CFC80 | N:WAVY |
| 0x4CFC88 | N:SQUAREWOBBLY |
🔗 Related Documents
Gluebie Spawn Mod
Gluebie Spawn Mod
Spawns animated Gluebie (tar blob) objects at Player 1's position on hotkey press.
Works globally in any race or arena.
What It Does
- Press the CE hotkey to spawn a Gluebie at Player 1's exact position
- Spawned Gluebies animate (wobble, rotate, scale) like original game Gluebies
- Balls within ~30 units of a spawned Gluebie get the tar effect:
- Ball slows down (velocity × 0.85 per frame)
- Tar sound plays on first contact
- When ball leaves range: recovers (flags cleared, normal speed restored)
- Up to 16 Gluebies can be spawned simultaneously
- Gluebie array clears on level/board change
How It Works
The Bug In v1
The original script used C array indices (0xB3, 0x1DA) as byte offsets in CE assembly.
In C, param_1[0xB3] accesses byte offset 0xB3*4 = 0x2CC, but in x86 assembly
[esi+0xB3] accesses byte offset 0xB3 directly. The correct byte offsets are:
| C Index | Byte Offset | Field |
|---|---|---|
| 0xB3 | 0x2CC | tar_collision_flag |
| 0x1DA | 0x768 | physics_enabled |
What Was Fixed
- Byte offset correction:
[esi+0xB3]→[esi+0x2CC],[esi+0x1DA]→[esi+0x768] - Tar sound effect: Calls
Sound_Play3D(0x459860) on first contact with a Gluebie- Gets sound resource via:
ball+0x14→ board →+0x878→ scene →+0x484 - Pushes ball X/Y/Z position as 3 floats + 1.0f (volume)
- Only plays when
ball+0x2CC == 0(first contact, not already tarred)
- Gets sound resource via:
- Recovery on leaving range: When ball moves away from all Gluebies:
- Clears
ball+0x2CC = 0(tar_collision_flag) - Restores
ball+0x768 = 1(physics_enabled = normal) - This lets the ball recover speed after leaving the tar area
- Clears
Tar Physics (in Ball_Update at 0x4081D9)
; Check physics_enabled flag
0x004081D9: MOV AL, [ESI+0x768]
0x004081DF: TEST AL, AL
0x004081E1: JNZ 0x004081F7 ; if 1 (normal) → speed boost
; TAR PATH (0x768 == 0): multiply blend by 0.85
0x004081E3: FLD [ESI+0x764] ; load vel_blend_factor
0x004081E9: FMUL [0x004CF4C0] ; × 0.85
0x004081EF: FSTP [ESI+0x764]
0x004081F5: JMP 0x0040823D
; NORMAL PATH (0x768 != 0): multiply blend by 1.1
0x004081F7: FLD [ESI+0x764]
0x004081FD: FCOMP [0x004CF538] ; compare with 89128.96
0x0040820A: MOV [ESI+0x764], 0x3C23D70A ; cap to 0.01
0x0040821A: FMUL [0x004CF4B8] ; × 1.1 (double)
0x00408233: MOV [ESI+0x764], 0x3F800000 ; cap to 1.0
Addresses
| Address | Function | Purpose |
|---|---|---|
| 0x405E22 | Ball_Update hook point | Per-frame ball physics tick |
| 0x437CB0 | Gluebie_ctor | Constructs Gluebie object (0x110C bytes) |
| 0x4BA57B | operator_new | Memory allocation |
| 0x461510 | MeshWorld_ctor | Loads Level3-Gluebie mesh |
| 0x453810 | AthenaList_Append | Adds to render/update lists |
| 0x459860 | Sound_Play3D | Plays 3D positioned sound |
| 0x4D0728 | "Level3-Gluebie" string | Mesh file name |
Ball Struct Offsets
| Offset | Type | Field |
|---|---|---|
| +0x014 | ptr | Board/level pointer |
| +0x018 | int | Player index (0 = Player 1) |
| +0x164 | float | Ball X position |
| +0x168 | float | Ball Y position |
| +0x16C | float | Ball Z position |
| +0x2CC | byte | tar_collision_flag (1 = tarred) |
| +0x768 | byte | physics_enabled (1 = normal, 0 = tar) |
| +0x764 | float | vel_blend_factor (decayed by 0.85 in tar) |
Gluebie Struct Offsets
| Offset | Type | Field |
|---|---|---|
| +0x10D0 | ptr | Scene pointer |
| +0x10D4 | float | Position X |
| +0x10D8 | float | Position Y |
| +0x10DC | float | Position Z |
| +0x10F0 | float | Random rotation (0-360) |
| +0x10F4 | float | Direction (1.0 or -1.0) |
| +0x10F8 | float | Wobble speed |
| +0x1108 | float | Scale factor (1.0) |
Constants
| Address | Type | Value | Purpose |
|---|---|---|---|
| 0x4CF4C0 | float32 | 0.85 | Tar friction multiplier |
| 0x4CF4B8 | float64 | 1.1 | Normal speed multiplier |
| 0x4CF538 | float64 | 89128.96 | Speed cap threshold |
| 0x4CF310 | float32 | 1.0 | Max blend factor |
🔗 Related Documents
half_size_all
half_size_all
Shrinks the player's ball to half size by inlining Ball_Shrink's physics fields
How It Works
Hooks Scene_SpawnBallsAndObjects at the point where the player ball has just been created and registered. A code cave checks if the ball's player index (ball+0x18) is 0, and if so, writes the same three fields that Ball_Shrink (0x00402200) sets — but without calling the function, so no sound effect plays.
Fields written (identical to Ball_Shrink):
| Ball Offset | Field | Value | Effect |
|---|---|---|---|
+0x284 |
radius | 13.0 (0x41500000) | Half visual + collision size |
+0x188 |
physics_scale | 2.5 (0x40200000) | Half max speed |
+0xC4C |
is_shrunk | 1 | Shrunk physics state |
Only player index 0 is affected. AI balls, split balls, follow balls, and board-init balls remain normal size.
Hook Details
| Hook Point | Address | Original Instruction | Catches |
|---|---|---|---|
| Scene_SpawnBallsAndObjects | 0x0041C8D7 | MOV byte [ESI+0x281], 0 (7 bytes) |
Player balls (loop, gated on index 0) |
The code cave:
CMP dword [ESI+0x18], 0— is this player 0?- If no → skip to step 4
- If yes → write radius=13.0, physics_scale=2.5, unused_init_flag=1 (NOTE: this flag is DEAD code — never read by any function, but we set it anyway to match original behavior)
- Execute original
MOV byte [ESI+0x281], 0 JMPback to 0x0041C8DE
Files
half_size_balls.c— C source codebass.dll— Compiled DLL (PE32 i386)half_size_balls.zip— Packaged zip
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dllinto the game folder - Launch Hamsterball
A Hamsterball_half_size.log file is written next to the EXE showing whether the hook applied.
🔗 Related Documents
half_size_p1
half_size_p1
Halves Player 1's ball size only (conditional code caves)
Files
half_size_p1.c— C source codebass_p1.dll— Compiled DLL (PE32 i386)half_size_p1.zip— Packaged zip
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dll(or renamed DLL) into the game folder - Launch Hamsterball
🔗 Related Documents
Hammy Judge System
Hammy Judge System (Expert Race)
Complete reverse-engineering analysis of the Judge ("Hammy Judge") system from the Expert Race in Hamsterball.
Overview
Hammy Judges are floating, spinning score-display objects placed around the Expert Race track. They show time values above their heads and activate when a ball rolls over E:ALERTJUDGES trigger zones.
Binary Addresses
| Function | Address | Purpose |
|---|---|---|
| LevelBoard_Expert_ctor | 0x41EA40 | Expert level constructor — pre-loads judge meshes |
| CreateSawblade (Arena Factory) | 0x40E590 | Factory — creates JUDGE objects from level refs |
| Gear_Level_ctor | 0x43A150 | Judge constructor |
| Judge_Reset | 0x434C40 | Activates judge (called by E:ALERTJUDGES) |
| Gear_Update (vtable[11]) | 0x434B60 | Per-frame update (countdown + rotation) |
| Gear_Render (vtable[18]) | 0x43A270 | Renders mesh + floating 3D text |
| ScoreDisplay_SetTime | 0x434C80 | Sets score text with random modifiers |
| ExpertCollisionEvents | 0x40E6A0 | Collision handler (E:ALERTJUDGES, E:SCORE) |
| Gear_Level_Dtor | 0x434B50 | Destructor |
| Gear_DeletingDtor | 0x43A250 | Deleting destructor |
Strings
| String | VA | Purpose |
|---|---|---|
JUDGE |
0x4CFA14 | Object name (factory lookup, strnicmp 5 chars) |
E:ALERTJUDGES |
0x4CFA70 | Collision event — activates all judges |
meshes\hammyjudge |
0x4D0AA8 | Mesh file for the judge visual |
E:SCORE |
— | Collision event — sets score on all judges |
Object Structure (0x1100 bytes = 4352)
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x0000 | 4 | ptr | Vtable pointer (0x4D52B8 = Gear vtable) |
| +0x10D0 | 4 | ptr | Board pointer |
| +0x10D4 | 4 | float | Position X |
| +0x10D8 | 4 | float | Position Y |
| +0x10DC | 4 | float | Position Z |
| +0x10E0 | 4 | float | Scale (RNG 0–10) |
| +0x10E4 | 4 | float | Rotation angle (RNG 0–360, +2.0/frame) |
| +0x10E8 | ~32 | char[] | Display text buffer ("%1.1f" formatted) |
| +0x10F0 | 4 | int | Text width (for centering) |
| +0x10F4 | 1 | byte | Active flag (1=active/idle, 0=activated/counting down) |
| +0x10F8 | 4 | int | Display value (init 400, decays ×0.8) |
| +0x10FC | 4 | int | Countdown timer (RNG 100–150 frames) |
Vtable (0x4D52B8)
| Index | Offset | Address | Function |
|---|---|---|---|
| 0 | 0x00 | 0x43A250 | Gear_DeletingDtor |
| 11 | 0x2C | 0x434B60 | Gear_Update (countdown + rotation) |
| 18 | 0x48 | 0x43A270 | Gear_Render (mesh + 3D text) |
Creation Flow
1. Level Constructor — LevelBoard_Expert_ctor (0x41EA40)
AthenaList_Init(board+0x4BBC, 0) ← initialize judge list (empty)
MeshNode("meshes\hammyjudge") → board+0x4BB0 ← pre-load judge mesh 1
MeshNode("meshes\hammyjudge") → board+0x4BB4 ← pre-load judge mesh 2
MeshNode("meshes\hammyjudge") → board+0x4BB8 ← pre-load judge mesh 3
The Expert level uses Level5.MESHWORLD for its geometry. This mesh file contains N:JUDGE ref points and E:ALERTJUDGES / E:SCORE collision triggers.
2. Factory — CreateSawblade (0x40E590)
When the level parser encounters N:JUDGE refs in the mesh:
if (strnicmp(name, "JUDGE", 5) == 0) {
obj = operator_new(0x1100); // 4352 bytes
Gear_Level_ctor(obj, board, x, y, z); // constructor
AthenaList_Append(board+0x4BBC, obj); // register to judge list
}
No difficulty gate! Unlike BONK (which checks App+0x23C != 0), JUDGE spawns on ALL difficulty levels.
3. Constructor — Gear_Level_ctor (0x43A150)
void Gear_Level_ctor(this, board, x, y, z) {
Level_ctor(this, d3d_device); // base class init
this->vtable = &Gear_Vtable; // 0x4D52B8
this->pos = {x, y, z}; // +0x10D4/+0x10D8/+0x10DC
this->board = board; // +0x10D0
this->scale = RNG(0, 10); // +0x10E0
this->active = 1; // +0x10F4 (starts ACTIVE)
this->display_value = 400; // +0x10F8
this->countdown = RNG(100, 150); // +0x10FC
this->rotation = RNG(0, 360); // +0x10E4
ScoreDisplay_SetTime(this, 0); // format display text
}
Activation — E:ALERTJUDGES
When a ball rolls over the E:ALERTJUDGES collision trigger, ExpertCollisionEvents (0x40E6A0) runs:
if (stricmp(event_name, "E:ALERTJUDGES") == 0) {
// Iterate ALL judges in board+0x4BBC list
for each judge in board+0x4BBC:
Judge_Reset(judge);
}
Judge_Reset (0x434C40)
void Judge_Reset(this) {
this->active = 0; // +0x10F4 = 0 (deactivate)
// If not already in general render list, add it
if (!AthenaList_Contains(board+0x2578, this)) {
AthenaList_Append(board+0x2578, this); // make visible
}
}
This starts the countdown — the judge becomes visible and its display begins decaying.
Update — Gear_Update (vtable[11] @ 0x434B60)
Runs every frame from Scene_Update:
void Gear_Update(this) {
if (this->active != 0) { // +0x10F4
// Active/idle — just spin
goto update_rotation;
}
// Inactive — counting down
this->countdown--; // +0x10FC
if (this->countdown > 0) {
goto update_rotation; // still waiting
}
// Countdown finished — decay display
if (this->display_value == 400) { // +0x10F8
// First decay step — play sound/effect at position
play_effect(board->app->mgr, pos.x, pos.y, pos.z, 1.0);
}
this->countdown = 0; // reset timer
this->display_value = (int)(this->display_value * 0.8); // decay
if (this->display_value < 1) {
this->display_value = 0; // fully decayed
}
update_rotation:
this->rotation += 2.0; // +0x10E4 (always spins)
}
Display Decay Sequence
Starting at 400, each step takes RNG(100–150) frames (~1.7–2.5 seconds at 60fps):
| Step | Value | Time |
|---|---|---|
| 0 | 400 | Initial (plays sound) |
| 1 | 320 | ~2s |
| 2 | 256 | ~2s |
| 3 | 204 | ~2s |
| 4 | 163 | ~2s |
| 5 | 131 | ~2s |
| ... | ... | ... |
| ~27 | 0 | Fully decayed (~54s total) |
Scoring — E:SCORE event
When a ball hits an E:SCORE trigger:
if (strnicmp(event_name, "E:SCORE", 7) == 0) {
long value = atol(event_name + 7); // parse number after "E:SCORE"
for each judge in board+0x4BBC:
ScoreDisplay_SetTime(judge, value);
}
ScoreDisplay_SetTime (0x434C80)
void ScoreDisplay_SetTime(this, base_time) {
float value = base_time + RNG(0, 2); // add random 0-2
switch (RNG(0, 5)) {
case 0: value += 0.0; break; // 20% no change
case 1: value -= 0.0; break; // 20% no change (DAT=0.0)
case 2: value += 1.0; break; // 20% +1.0
default: break; // 40% no change
}
// Format as text
if (value < 0 || value > 10) {
sprintf(this->display_buffer, "%s", ""); // empty if out of range
} else {
sprintf(this->display_buffer, "%1.1f", value);
}
this->text_width = Font_MeasureText(this->display_buffer) / 2;
}
Rendering — Gear_Render (vtable[18] @ 0x43A270)
void Gear_Render(this) {
// Apply scale
Gfx_ScaleX(this->scale + 290.0); // +0x10E0 + 290
// Position with oscillation (bobbing animation)
float bob = Wave_Sin(this->rotation) * 0.0;
float y = this->pos_y - 20.0 - this->countdown + bob;
Gfx_SetPosition(this->pos_x, y, this->pos_z);
// Draw the hammyjudge mesh
board->judge_mesh_1->vtable[0x1C](); // render mesh
// Draw floating 3D score text
Font_DrawGlyph3D(
board->app->font, // font resource
this->display_buffer, // text ("%1.1f" formatted)
pos_with_transforms, // position
scale, 0, -1.0, 0, 1.0, 1.0, 0, // orientation/scale
...
);
}
The judge mesh is drawn at board+0x4BB0's MeshNode render function, which renders the pre-loaded meshes\hammyjudge mesh. The 3D text floats above showing the score value.
Key Constants
| Address | Value | Purpose |
|---|---|---|
| 0x4D5318 | 0.8 | Display value decay multiplier |
| 0x4CF48C | 2.0 | Rotation increment per frame |
| 0x4D5C80 | 290.0 | Base scale offset |
| 0x4CF370 | 20.0 | Y position offset (raises judge above ground) |
| 0x4CF9F8 | 10.0 | RNG range for scale |
| 0x4CF310 | 1.0 | Score modifier (add) |
Board Fields
| Offset | Size | Description |
|---|---|---|
| +0x4BB0 | 4 | Judge mesh 1 (MeshNode) |
| +0x4BB4 | 4 | Judge mesh 2 (MeshNode) |
| +0x4BB8 | 4 | Judge mesh 3 (MeshNode) |
| +0x4BBC | ~24 | AthenaList — Judge object list |
| +0x4FC8 | 4 | Internal AthenaList iteration array |
ExpertCollisionEvents Events
The Expert board collision handler (0x40E6A0) processes these events:
| Event | Action |
|---|---|
E:CALLHAMMER |
CreateBonkPopup (Bonk appears) |
E:HAMMERCHASE |
Hammer_ChaseStart (Bonk chases) |
E:ALERTSAW1/2 |
Saw_AlertActivate (Sawblade warning) |
E:ACTIVATESAW1/2 |
Saw_Activate (Sawblade fires) |
E:ALERTJUDGES |
Judge_Reset on all judges (activate countdown) |
E:SCORE |
ScoreDisplay_SetTime on all judges (set score) |
E:JUMP |
Jump boost (Ball_DizzyImmunity +200) |
E:BELL |
Bell_Activate + extra time bonus |
Notes
- The Expert race level uses
Level5.MESHWORLDfor its geometry - Three judge meshes are pre-loaded but the number of judge objects depends on how many
N:JUDGErefs exist in the mesh - Judges are NOT difficulty-gated (unlike BONK which requires
App+0x23C != 0) - The judge continuously spins at +2.0 degrees/frame regardless of state
- The score display uses random ±modifiers to create slight variation between judges
- When
E:ALERTJUDGESfires, all judges activate simultaneously and begin their countdown - The display value decays by ×0.8 each step (400→320→256→...) over ~54 seconds total
🔗 Related Documents
hbtestd
hbtestd — Automated Hamsterball Testing Daemon
An MCP server that runs Hamsterball under Wine/Xvfb and exposes tools for screenshots, input, and live runtime telemetry.
What It Does
- Starts a virtual X display (
Xvfb :99) - Launches the original
Hamsterball.exeunder Wine withLIBGL_ALWAYS_SOFTWARE=1 - Exposes an MCP server with HTTP/SSE transport on
http://127.0.0.1:8777/sse - Provides tools to:
- Start / stop / restart the game
- Capture screenshots on demand
- Send keyboard input (arrows, Enter, Escape, Space, WASD, etc.)
- Click at screen coordinates
- Read runtime telemetry (process stats + internal FPS values)
- Estimate FPS from frame capture timing
Install
cd /home/evan/hamsterball-re/tools/hbtestd
uv venv
uv pip install -e .
Run
./run_server.sh
Connect from Hermes
Add to ~/.hermes/config.yaml under mcp_servers:
mcp_servers:
hbtestd:
transport: sse
url: http://127.0.0.1:8777/sse
Then restart Hermes or reload MCP servers.
Tools
| Tool | Description |
|---|---|
start_game() |
Launch Hamsterball |
stop_game() |
Kill game + Wine + Xvfb |
restart_game() |
Clean restart |
get_status() |
PID, CPU, memory, runtime |
screenshot() |
Save PNG and return path |
screenshot_base64() |
Return PNG as base64 |
send_key(key) |
Single keypress |
hold_key(key, duration_ms) |
Hold key |
send_text(text) |
Type text |
mouse_click(x, y, button) |
Click at coordinates |
get_telemetry() |
Internal FPS targets & process stats |
estimate_fps(samples) |
Approximate FPS from captures |
wait(seconds) |
Sleep |
Environment Variables
| Variable | Default | Description |
|---|---|---|
HBTESTD_GAME_DIR |
/home/evan/hamsterball-wasm/boxedwine-package/hamsterball |
Game install directory |
HBTESTD_GAME_EXE |
Hamsterball.exe |
Game executable name |
HBTESTD_DISPLAY |
:99 |
Virtual display number |
HBTESTD_RESOLUTION |
800x600x24 |
Xvfb screen spec |
HBTESTD_PORT |
8777 |
MCP SSE port |
HBTESTD_SCREENSHOT_PATH |
/tmp/hbtestd_screenshot.png |
Default screenshot file |
HBTESTD_LOG_PATH |
/tmp/hbtestd.log |
Game stdout/stderr log |
HBTESTD_LIBGL_SOFTWARE |
1 |
Force llvmpipe software rendering |
Runtime Telemetry
get_telemetry() reads from the Windows PE static addresses running inside the Wine process:
g_Appat0x005341E0App+0x16C→ target update FPS (default 100)App+0x170→ target render FPS (default 75)
This requires ptrace access to the Wine process. If kernel.yama.ptrace_scope blocks it, you will still get process statistics but not the internal FPS values.
Requirements
- Wine
- Xvfb
- xdotool
- scrot
- Python ≥3.10
- MinGW cross compiler (if you want to build the fps unlock DLL separately)
🔗 Related Documents
Input System
Hamsterball Input System — Complete Reverse-Engineering Document
Scope: Original Hamsterball.exe (PE32, i386, Athena engine).
Method: Direct Ghidra decompilation of original binary.
Last Updated: 2026-06-02
Table of Contents
- Input Architecture Overview
- Core Data Structures
- Input Device Lifecycle
- Key-to-Action Mapping (Registry)
- Runtime Input Processing
- options menu / Remap UI](#options-menu--remap-ui)
- DirectInput Integration
- Function Reference
- Offsets Cheat-Sheet
- Modding Notes
Input Architecture Overview
The original Hamsterball uses DirectInput 8 for all player input. The top-level owner is App; it creates two related objects:
| Member | Size | Stored At | Description |
|---|---|---|---|
| InputDevice (keyboard/mouse) | 0x91C | App+0x178 |
Created by App_CreateInputDevice (0x46C050) |
| InputHandler (4 control slots) | 0x438 | App+0x180 |
Created by App_CreateInputHandler (0x46C110) |
Ball_GetInputForce (0x46EC30) is the main entry-point that converts raw input state into a 2-D force vector used by the physics engine. It supports three families of input:
| Switch Case | Mode | Source |
|---|---|---|
1 |
Keyboard | DirectInput keyboard device, DIK scan-codes at InputDevice+0x50C..0x518 |
2 |
Mouse | Cursor position offset from screen centre, clipped by App+0x15A centre-capture flag |
4-7 |
Joystick / Gamepad | 4 DirectInput gamepad devices polled into GamepadDevice structs at InputHandler+0x10C/0x110 |
Core Data Structures
App (top-level singleton)
The global App instance is at DAT_005341E0. Constructor is App_Ctor (0x46DC40).
| Offset | Type | Name | Notes |
|---|---|---|---|
+0x04 |
void* |
graphics |
GraphicsDevice |
+0x08 |
void* |
window |
Win32 window handle |
+0x0C |
void* |
scene |
Active Scene |
+0x10 |
int |
scene_type |
0=menu, 1=level |
+0x54 |
void* |
registry |
RegKey object (HKCU SOFTWARE key) |
+0x15A |
bool |
mouse_centre_capture |
If true Ball_GetInputForce recentres the cursor every frame |
+0x158 |
bool |
fullscreen |
Full-screen flag |
+0x15C |
int |
screen_width |
e.g. 640 |
+0x160 |
int |
screen_height |
e.g. 480 |
+0x174 |
void* |
graphics_device |
D3D8 device wrapper |
+0x178 |
InputDevice* |
input_device |
Keyboard / mouse DInput device |
+0x17C |
Audio_MusicDevice* |
music_device |
BASS audio |
+0x180 |
InputHandler* |
input_handler |
4-slot control bindings + gamepad array |
+0x1A0 |
int |
player1_mode |
1=keyboard, 2=mouse, 4-7=joy |
+0x1A4 |
int |
player2_mode |
Same enum for P2 |
+0x208 |
char* |
status_text |
Debug status string ("Initialize(x)") |
+0x224 |
SimpleMenu* |
current_menu |
Active menu object |
+0x228 |
void* |
results_dialog |
Race-end results screen |
+0x240 |
HCURSOR |
blank_cursor |
Invisible cursor handle |
+0x278 |
Texture* |
shadow_texture |
Ball shadow sprite |
+0x2A8 |
float |
mouse_sensitivity |
Multiplier for mouse mode |
+0x2AC |
float |
music_volume |
0.0-1.0 |
+0x2B0 |
float |
sfx_volume |
0.0-1.0 |
+0x2B4 |
bool |
pause_with_right_button |
If true RMB pauses the game |
+0x2B8 |
bool |
registered |
Full-game flag |
+0x2C0 |
float |
time_remaining |
Race timer |
+0x2F8 |
bool |
is_tournament |
Tournament mode flag |
+0x300 |
char[32] |
player1_name |
Tournament name |
+0x320 |
char[32] |
player2_name |
Tournament name |
+0x340 |
float |
best_time |
Saved best lap |
+0x344 |
float |
target_time |
Target for medals |
+0x350 |
int |
free_plays |
Demo counter |
+0x354 |
int |
play_count |
Total launches |
+0x358 |
int |
unlock_flags |
Secret level unlock bits |
+0x3E0 |
void* |
high_scores_table |
High-score data |
+0x534 |
MusicDevice* |
jukebox |
Background music |
| +0xB28 | DWORD | CONTROL1 | Raw key/button DWORD (see registry section) |
| +0xB2C | DWORD | CONTROL2 | " |
| +0xB30 | DWORD | CONTROL3 | " |
| +0xB34 | DWORD | CONTROL4 | " |
InputDevice (0x91C bytes)
Allocated with operator_new(0x91C) in App_CreateInputDevice (0x46C050). The constructor at 0x466620 (named SoundDevice_ctor in older labels — it is actually a generic device constructor) sets the DirectInput cooperative level and fills the default keyboard DIK codes.
| Offset | Type | Name | Notes |
|---|---|---|---|
+0x00 |
vtable* |
vtable | PTR_InputDevice_DeletingDtor |
+0x04 |
AthenaList |
sub_devices |
AthenaList of sub-device pointers |
+0x41C |
AthenaList |
?? |
Second list (possibly device caps) |
+0x434 |
void* |
dinput_device |
IDirectInputDevice8* for keyboard |
+0x508 |
int |
device_type |
See DEVICE_TYPE_KEYBOARD etc. below |
+0x50C |
int |
key_left |
DIK scan-code for "left" |
+0x510 |
int |
key_right |
DIK scan-code for "right" |
+0x514 |
int |
key_up |
DIK scan-code for "forward/up" |
+0x518 |
int |
key_down |
DIK scan-code for "backward/down" |
+0x51C |
int |
key_escape |
DIK scan-code for escape/pause |
+0x520 |
int |
key_pause |
DIK scan-code for pause (second binding) |
Note: The reimplementation and some community docs claim 6 directional keys exist; the decompilation of
Ball_GetInputForce(0x46EC30) only reads 4 directional keys (+0x50Cthrough+0x518). There is no explicit "brake" or "jump" action read here — jumping is handled exclusively byE:JUMPcollision objects.
InputHandler (0x438 bytes)
Allocated with operator_new(0x438) in App_CreateInputHandler (0x46C110). Constructor is at roughly 0x46DFA0 (named FUN_0046dfa0 in Ghidra).
| Offset | Type | Name |
|---|---|---|
+0x00 |
vtable* |
vtable |
+0x04 |
App* |
back-pointer to App |
+0x0C |
int[4] |
player_device_index |
+0x1C |
int[4] |
player_input_mode |
+0x2C |
int[4] |
?? |
+0x3C |
int |
joy_count |
+0x40 |
GamepadDevice*[4] |
gamepads |
+0x10C |
int |
joy0_x |
+0x110 |
int |
joy0_y |
GamepadDevice (layout inferred from Ball_GetInputForce)
Polled by GamepadDevice_PollState during InputDevice_PollAndRelease.
| Offset | Type | Name |
|---|---|---|
+0x130 |
char |
button_a |
+0x131 |
char |
button_b |
+0x132 |
char |
button_x |
+0x133 |
char |
button_y |
+0x10C |
int |
axis_x |
+0x110 |
int |
axis_y |
Input Device Lifecycle
1. Construction — App_Initialize_Full (0x429530)
The full 26-step init sequence creates devices in this order:
| Step | Function | What it does |
|---|---|---|
| 1 | App_Initialize (0x46BB40) |
Base init, D3D8 device, window |
| 15 | App_CreateInputHandler (0x46C110) |
Alloc 0x438, ctor, store at App+0x180 |
| 16 | FUN_0046dfc0(slot0, 1) |
Bind slot 0 to keyboard (mode 1) |
| 17-18 | FUN_0046dfa0 / FUN_0046dfc0(slot1, 2) |
Bind slot 1 to mouse (mode 2) |
| 19-20 | FUN_0046dfa0 / FUN_0046dfc0(slot2, 4) |
Bind slot 2 to joy0 (mode 4) |
| 21-22 | FUN_0046dfa0 / FUN_0046dfc0(slot3, 5) |
Bind slot 3 to joy1 (mode 5) |
| 23 | RegKey_Close |
Close registry handle |
The InputHandler constructor reads the registry keys CONTROL1..CONTROL4 and stores the raw DWORDs at App+0xB28..0xB34 (see next section).
2. Per-Frame Polling — InputDevice_PollAndRelease (0x46EBD0)
void InputDevice_PollAndRelease(int self)
{
// 1. Poll keyboard via DirectInput (256-byte state buffer at +0xC)
int didev = *(int*)(self + 0x434);
if (didev != 0) {
int hr = IDirectInputDevice8_GetDeviceState(didev, 0x100, self + 0xC);
if (hr < 0) {
IDirectInputDevice8_Acquire(didev);
memset(self + 0xC, 0, 0x100); // 256 bytes = full DI8DEVSTATE size
}
}
// 2. Poll up to 4 gamepads
int *slot = (int*)(self + 0x424);
for (int i = 0; i < 4; ++i) {
if (*slot != 0) GamepadDevice_PollState(*slot);
slot++;
}
}
- The
0x100bytes starting atInputDevice+0x0Cis the standard DirectInput keyboard state array (256 entries, one per scan-code). Each byte uses the high bit (& 0x80) for "key is currently down". InputDevice+0x424is the start of a 4-elementint[]holding gamepad device pointers.
3. Destruction — KeyboardDevice_ScalarDtor (0x46E910)
Called on game exit. Releases the IDirectInputDevice8 pointer and frees the InputDevice heap block.
Key-to-Action Mapping (Registry)
Registry Keys
Under HKCU\Software\Raptisoft\Hamsterball:
| Key | Type | Default | Meaning |
|---|---|---|---|
CONTROL1 |
DWORD | 0x00000063 (99) |
Binding slot 0 → Keyboard |
CONTROL2 |
DWORD | 0x00000064 (100) |
Binding slot 1 → Mouse |
CONTROL3 |
DWORD | varies | Binding slot 2 → Joystick / Gamepad |
CONTROL4 |
DWORD | varies | Binding slot 3 → Joystick / Gamepad |
CONTROL1andCONTROL2are hard-coded to keyboard (99) and mouse (100) byInputHandlerctor.- The values written to registry are NOT DIK scan-codes in the obvious way; they are internal mode indicators (99 = kbd, 100 = mouse, other values = joy index).
- When the user chooses "REMAP KEYBOARD CONTROLS" in the options menu, the values at
App+0xB28..0xB38are updated and later flushed to registry on exit.
Default Keyboard DIK Codes (hard-coded in constructor)
From the InputDevice constructor (0x466620) disassembly, the default scan-codes populated are:
| Action | DIK Code | Hex | Description |
|---|---|---|---|
| Left / Turn Left | DIK_LEFT |
0xCB (203) |
Arrow left |
| Right / Turn Right | DIK_RIGHT |
0xCD (205) |
Arrow right |
| Up / Roll Forward | DIK_UP |
0xC8 (200) |
Arrow up |
| Down / Roll Backward | DIK_DOWN |
0xD0 (208) |
Arrow down |
| Escape / Pause | DIK_ESCAPE |
0x01 (1) |
Escape key |
| Pause (alt) | DIK_P |
0x19 (25) |
P key |
Important: There is no separate "brake" key — releasing the up/down arrows simply stops applying force. There is no "jump" key in the input system; ball jumping is caused by
E:JUMPcollision events handled inDispatchCollisionEvents(0x40C5D0).
Registry Persistence
Registry read happens once during InputHandler construction. Registry write happens in SoundDevice_dtor (0x4668A0) — the same function also saves Sound Volume. The exact registry path is built by RegKey_SetSoftwarePath (0x472F50):
HKCU\Software\Raptisoft\Hamsterball
Runtime Input Processing
Input_IsKeyDown (0x46E0B0)
uint Input_IsKeyDown(void* self, int key)
{
int mode = *(int*)(self + 8) - 1; // 0=kbd, 1=mouse, 3-6=joyN
switch (mode) {
case 0: // Keyboard
if (key == -1) {
// Check ANY key down (scan whole 256-byte buffer)
byte* state = (byte*)(*(int*)(*(int*)(self + 4) + 0x434) + 0xC);
return any_nonzero(state, 256);
}
else if (key == 0) {
return (state[0x45] >> 7); // ??? special binding
}
else if (key == 1) {
return App_Is2PMode(...);
}
else {
byte* state = (byte*)(*(int*)(*(int*)(self + 4) + 0x434) + 0xC + key);
return (*state != 0); // high-bit test
}
case 1: // Mouse
// Returns left/right/middle button state from Win32 mouse struct
// iVar4 + 0x1C8, 0x1C9, 0x1CA
return ...
case 3: case 4: case 5: case 6: // Joystick
int pad = *(int*)(self + 0x10); // gamepad struct ptr
if (pad) {
if (key == -1) return any_button_on_pad(pad);
return *(char*)(pad + 0x130 + key) != 0;
}
}
return 0;
}
self+8stores the input mode (1 = kbd, 2 = mouse, 4-7 = joy).self+4is a back-pointer to theApp.- For the keyboard branch, the actual DI8 state buffer is reached by:
App->input_device->dinput_device->+0xC(the 256-byte keyboard state array).
Ball_GetInputForce (0x46EC30)
This is the main physics input function — every frame it produces a (forceX, forceY) float pair.
void Ball_GetInputForce(void* this, float* outForce)
{
float forceX = 0.0f;
float forceY = 0.0f;
switch (*(int*)(this + 8)) { // player input mode
case 1: // Keyboard
{
int input_dev = *(int*)(*(int*)(this + 4) + 0x434); // InputDevice ptr
byte* kbd = (byte*)(input_dev + 0xC); // DI8 state
int left = *(int*)(input_dev + 0x50C); // DIK code
int right = *(int*)(input_dev + 0x510);
int up = *(int*)(input_dev + 0x514);
int down = *(int*)(input_dev + 0x518);
if (kbd[left] & 0x80) forceX = -1.0f;
if (kbd[right] & 0x80) forceX = 1.0f;
if (kbd[up] & 0x80) forceY = -1.0f; // Note: screen-up is -Y
if (kbd[down] & 0x80) forceY = 1.0f;
break;
}
case 2: // Mouse
{
POINT pt; GetCursorPos(&pt);
int cx = App->screen_width / 2;
int cy = App->screen_height / 2;
forceX = (float)(pt.x - cx);
forceY = (float)(pt.y - cy);
if (App->mouse_centre_capture)
SetCursorPos(cx, cy);
break;
}
case 4: case 5: case 6: case 7: // Joy
{
int pad = *(int*)(this + 0x10); // GamepadDevice*
if (pad != 0) {
forceX = (float)(*(int*)(pad + 0x10C) / 100);
forceY = (float)(*(int*)(pad + 0x110) / 100);
Vec3_NormalizeAndScale(&pt, 1.0f);
forceY = (float)pt.y;
forceX = (float)pt.x;
}
break;
}
}
float scale = *(float*)(this + 0xC); // per-ball sensitivity
outForce[0] = scale * forceX;
outForce[1] = scale * forceY;
}
Key observations for modders:
this+8= player input mode,this+4=App*,this+0xC= sensitivity float.- The keyboard branch only reads 4 directional keys. There is no jump, no brake, no action button.
- The Y axis is inverted:
-1.0is "up" on screen (toward smaller Y) because D3D uses a top-down coordinate system for this engine. - Mouse mode uses absolute cursor offset from screen centre, not delta motion.
Input_CheckKeyCombo (0x428F10)
Used by menus (and the pause system) to detect "any key pressed in slot" with a 50-frame debounce.
int Input_CheckKeyCombo(void* self, int slot)
{
if (slot == 2) { // special escape/pause check
int dev = *(int*)(*(int*)(self + 0x180) + 0x434); // InputDevice
byte esc = *(byte*)(dev + 0x51C + 0xC); // escape DIK state
byte pau = *(byte*)(dev + 0x520 + 0xC); // pause DIK state
if ((esc & 0x80) || (pau & 0x80)) {
if (*(int*)(self + 0x560) == 0) {
*(int*)(self + 0x560) = 0x32; // 50 frame cooldown
return 1;
}
}
}
if (slot < 4) {
int* combo = (int*)(self + slot*4 + 0x550); // combo state array
while (slot < 4) {
if (*(int*)(combo + 4) == 0) { // cooldown timer == 0?
int down = Input_IsKeyDown((void*)*combo, -1);
if (down) {
*(int*)(self + slot*4 + 0x560) = 0x32; // set 50-frame timer
return 1;
}
}
slot++;
combo++;
}
}
return 0;
}
App+0x550..0x55C= 4InputComboobjects (one per control slot).App+0x560..0x56C= 4intcooldown counters (50 frames = ~0.8 s at 60 Hz).
Options Menu / Remap UI
OptionsMenu_ctor (0x442CE0)
The options menu is a UIList-based menu (SimpleMenu -> OptionsMenu). Items are added in this order:
0: "RESOLUTION: 1024 X 768" -> "REZ"
1: "FULLSCREEN: YES" -> "FS"
2: "COLOR QUALITY: MEDIUM" -> "CM"
3: "SAFE MODE: OFF" -> "SM"
4: <spacer 10>
5: "SOUND VOLUME:" -> "SV"
6: "MUSIC VOLUME:" -> "MV"
7: <spacer 10>
8: "REMAP KEYBOARD CONTROLS" -> "REMAP"
9: <spacer 10>
10: "MOUSE SENSITIVITY:" -> "MS"
11: "PAUSE W/RIGHT BUTTON: YES" -> "PWRB"
12: <spacer 10>
13: "BACK" -> "BACK"
Selecting item "REMAP" enters the remap sub-menu. That sub-menu code is not fully decompiled here, but the render function shows the visual binding representation.
OptionsMenu_RenderControls (0x42E840)
void OptionsMenu_RenderControls(void* menu)
{
int binding = 0;
int offset = 0xB28; // App+0xB28 = CONTROL1
while (offset < 0xB38) {
Matrix_Scale4x4(...);
int val = *(int*)(offset + *(int*)(menu + 0x878)); // read CONTROLx
int duplicate = 0;
if (val == 99) {
// Keyboard icon (scale x=0.5)
} else if (val == 100) {
// Mouse icon (scale x=0.5)
} else {
// Joystick / other — check for duplicates
int check = 0xB28;
int idx = 0;
while (check < 0xB38) {
if (idx != binding &&
*(int*)(check + App) == val) {
// Duplicate binding → render in RED
Matrix_Scale4x4(..., 1.0f, 0.0f, 0.0f, 1.0f);
}
check += 4;
idx++;
}
}
binding++;
// Draw "CONTROL%d" label with chosen colour
AthenaString_SprintfToBuffer(buf, "CONTROL%d");
UIList_SetColorsByName(menu, ..., buf);
offset += 4;
}
}
- The menu reads
App+0xB28..0xB38(the 4 DWORD control bindings). - Value 99 = keyboard (rendered with magenta tint in UIList).
- Value 100 = mouse (rendered with cyan tint).
- Any other value = a joystick/gamepad index; if two slots share the same value the second is drawn in red to warn the player.
- The actual remapping UI (where the user presses a key to rebind) is likely a small tight loop inside the menu update function — it has not been decompiled in this round but its address can be found by xref-ing
OptionsMenu_ctorfrom the vtable handler atUIListitem"REMAP".
DirectInput Integration
Enumeration & Cooperative Level
DirectInput8Create (imported at 0x47C7F0 from dinput8.dll) is called inside the InputDevice constructor. The constructor then:
- Creates the IDirectInput8 device (
DllEntryPointin decomp — actuallyDirectInput8Create). - Calls
IDirectInputDevice8_SetCooperativeLevelwith:- Window handle =
App->window - Flags =
DISCL_NONEXCLUSIVE | DISCL_BACKGROUND(orDISCL_EXCLUSIVE | DISCL_FOREGROUNDfor joystick)
- Window handle =
- Calls
IDirectInputDevice8_SetDataFormatwithc_dfDIKeyboardorc_dfDIJoystick. - Acquires the device.
The exact cooperative-level path is visible in the decompilation of InputDevice constructor (0x466620) — the branch at offset 0x46666C checks a flag and either calls with 2 (BACKGROUND) or 3 (FOREGROUND).
Device Acquisition Failure Path
In InputDevice_PollAndRelease (0x46EBD0):
int hr = IDirectInputDevice8_GetDeviceState(didev, 0x100, buffer);
if (hr < 0) {
IDirectInputDevice8_Acquire(didev);
memset(buffer, 0, 0x100); // zero state on failure
}
If the device is lost (Alt-Tab, UAC popup, etc.), the game automatically re-acquires it and returns a zeroed state for that frame — preventing phantom inputs.
Default DirectInput8 DLL Import
| Import | Address | Called From |
|---|---|---|
DirectInput8Create |
0x0047C7F0 |
InputDevice constructor (0x466620) |
Function Reference
Input System Functions
| Address | Name | Args | Description |
|---|---|---|---|
| 0x46EC30 | Ball_GetInputForce | (void* this, float* outForceXY) |
Convert player input to 2-D force vector for physics |
| 0x46E0B0 | Input_IsKeyDown | (void* this, int key) |
Check if key/button is currently pressed (mode-aware) |
| 0x46EBD0 | InputDevice_PollAndRelease | (int device) |
Poll DInput kb + 4 gamepads, re-acquire if lost |
| 0x428F10 | Input_CheckKeyCombo | (void* app, int slot) |
50-frame debounced "any key in slot" check |
| 0x42E840 | OptionsMenu_RenderControls | (void* menu) |
Draw control icons coloured by device type |
| 0x442CE0 | OptionsMenu_ctor | (void* this, int arg1, int arg2) |
Build options menu including REMAP entry |
| 0x46C050 | App_CreateInputDevice | (int app) |
Alloc+construct InputDevice (0x91C) store at App+0x178 |
| 0x46C110 | App_CreateInputHandler | (int app) |
Alloc+construct InputHandler (0x438) store at App+0x180 |
| 0x46DC40 | App_Ctor | (void* this) |
Full App init; creates MeshWorld, cursors, COM |
| 0x46E910 | KeyboardDevice_ScalarDtor | (void* this) |
Release DInput device, free InputDevice |
| 0x466620 | InputDevice_ctor (mis-label "SoundDevice") | (void* this, int app) |
Set coop-level, alloc kb buffer, default DIK codes |
| 0x4692F0 | Scene_HandleInput | (void* scene) |
Menu item iteration + input dispatch |
| 0x47C7F0 | DirectInput8Create | (import) | Standard DInput8 creation |
Registry Functions
| Address | Name | Description |
|---|---|---|
| 0x472EC0 | RegKey_Ctor |
Open HKCU\Software\Raptisoft\Hamsterball |
| 0x472F30 | RegKey_Close |
Close key handle |
| 0x473030 | RegKey_WriteDWORD |
Persist a DWORD |
| 0x473100 | RegKey_QueryValue |
Read a value by name |
| 0x473170 | RegKey_ReadString |
Read string (not used for controls) |
Offsets Cheat-Sheet
App Struct (global DAT_005341E0)
| Offset | Size | Meaning |
|---|---|---|
+0x178 |
4 | InputDevice* |
+0x180 |
4 | InputHandler* |
+0x1A0 |
4 | Player 1 input mode (1=kbd, 2=mouse, 4-7=joy) |
+0x15A |
1 | mouse_centre_capture bool |
+0x15C |
4 | screen_width |
+0x160 |
4 | screen_height |
+0x434 |
4 | InputDevice* (duplicate path) |
+0x550 |
4x4 | 4 InputCombo* slot objects |
+0x560 |
4x4 | 4-frame debounce counters |
| +0xB28 | 4 | CONTROL1 (raw binding DWORD) |
| +0xB2C | 4 | CONTROL2 |
| +0xB30 | 4 | CONTROL3 |
| +0xB34 | 4 | CONTROL4 |
InputDevice (0x91C bytes)
| Offset | Size | Meaning |
|---|---|---|
+0x00 |
4 | vtable |
+0x04 |
0x418 | AthenaList (sub-devices) |
+0x41C |
0x10 | Second AthenaList |
+0x434 |
4 | IDirectInputDevice8* keyboard |
+0x508 |
4 | device_type |
| +0x50C | 4 | key_left DIK code |
| +0x510 | 4 | key_right DIK code |
| +0x514 | 4 | key_up DIK code |
| +0x518 | 4 | key_down DIK code |
+0x51C |
4 | key_escape DIK code |
+0x520 |
4 | key_pause DIK code |
+0x0C |
0x100 | DirectInput keyboard state buffer (256 bytes) |
+0x424 |
4x4 | Gamepad device pointers (4 slots) |
InputHandler (0x438 bytes)
| Offset | Size | Meaning |
|---|---|---|
+0x00 |
4 | vtable |
+0x04 |
4 | App* back-pointer |
+0x0C |
4x4 | player_device_index[4] |
+0x1C |
4x4 | player_input_mode[4] |
+0x40 |
4x4 | GamepadDevice*[4] |
+0x10C |
4 | joy0_x |
+0x110 |
4 | joy0_y |
Ball (relevant fields for input)
| Offset | Size | Meaning |
|---|---|---|
+0x08 |
4 | Player input mode (copied from App) |
+0x0C |
4 | Sensitivity float (default 1.0) |
+0x10 |
4 | GamepadDevice* (for joy modes) |
Modding Notes
Changing Default Key Bindings
If you want to patch the default keys (before the user remaps), edit the immediate values written in InputDevice_ctor (0x466620). The four mov dword ptr [reg+N], imm32 instructions near +0x40 through +0x50 set the initial DIK codes.
Adding a New Input Mode
The switch(mode) in Ball_GetInputForce only handles cases 1, 2, and 4-7. If you add case 3 (or re-purpose an unused case), you must also:
- Update
InputDevice_PollAndReleaseto poll the new hardware. - Update
Input_IsKeyDownto read the new device state. - Update
OptionsMenu_RenderControlsto recognise the new mode value (or it will render as a red duplicate).
Disabling the 50-Frame Debounce
Input_CheckKeyCombo stores 0x32 (50 decimal) into App+0x560..0x56C to prevent repeated triggers. Patch the immediate 0x32 at 0x428F1A and 0x428F3A to 0x01 or 0x00 for instant re-triggering.
Mouse-Sensitivity Scaling
Ball_GetInputForce case 2 does not apply App->mouse_sensitivity (App+0x2A8). The raw cursor offset is used directly. To add sensitivity scaling, patch a multiply by *(float*)(App + 0x2A8) before the break; at offset 0x46ECXX.
Registry-Free Operation (Portable Mode)
All four CONTROLx values are read from registry during InputHandler construction. If the registry key does not exist, the constructor falls back to hard-coded defaults (99, 100, joy0, joy1). To make the game fully portable, patch RegKey_QueryValue (0x473100) to always return failure (0) so defaults are always used.
Multi-player Input
The game supports up to 4 local players (Party Race / Rodent Rumble). Each player gets a control slot (0..3) and the InputHandler maps slots to physical devices. The InputDevice+0x424 array holds up to 4 gamepad structs; keyboard and mouse are treated as "virtual" device #0 and #1.
Document compiled from live Ghidra decompilation of Hamsterball.exe. All offsets verified against the PE binary loaded at 0x00400000. For questions or corrections, open an issue in the hamsterball-re repository.
🔗 Related Documents
Jump Mod (Touch)
Jump Mod (Touch)
Info
- File:
bass.dll(proxy) - Controls: Tap the screen to jump
- Android-safe: Code cave is pure asm, no IAT hooks, no GetTickCount
What it does
Adds a jump mechanic to the ball! Tap the screen (Winlator maps touch to left-click) and the ball gets an upward impulse.
How it works:
- Background thread polls left mouse button (touch tap) every 16ms
- On tap: runs a raycast straight down from the ball using the game's own
Mesh_FindClosestCollisionfunction - If the raycast hits ground within
radius × 1.45→ ball is grounded → jump allowed - If airborne → jump denied (no double jumping)
- Phase 15 code cave (pure asm): adds upward impulse to
ball+0x174(Y force)
Safety gates (jump only works when):
- Countdown finished (Scene+0x3A4C = 1)
- Race not ended (App+0x5D6 = 0)
- Player not flagged (App+0x5D5 = 0)
- Ball is on the ground (raycast confirmed)
Winlator Safety
- Phase 15 code cave is pure FPU asm (FLD/FADD/FSTP/MOV/JMP) — no C function calls mid-function
- Raycast runs in background thread (safe C context)
- No IAT hooks, no GetTickCount
- All pointer accesses guarded by IsBadReadPtr
Build
i686-w64-mingw32-gcc -shared -o bass.dll jump_touch.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Installation
- Rename original
bass.dlltobass_real.dll - Copy mod
bass.dllto game folder - On Android/Wine: set DLL override to
nativeforbass.dll - Tap screen in-game to jump!
🔗 Related Documents
jump_mod v22
jump_mod v22
Press SPACE to jump (Player 1 only, raycast ground detection).
What changed from v20
v22 adds countdown and race-end gating:
-
Countdown gate: Before allowing a jump, checks
Scene+0x3A4C
(countdown_done flag). This flag is set to 1 byScene_HandleRaceEnd
(0x41B130) when all 3 Ready/Set/Go phases complete. If 0, the game
itself blocks all input inScene_vmethod31(0x41AC70) — the jump
mod now mirrors this behavior. -
Race-end gate: Checks
ball+0x14C(freeze flag). Set to 1 by
Scene_HandleRaceEndat 0x41B40D when the race timer expires
(player touches the goal). Also checked by the game'sBall_Update
at 0x4060A1. When set, the ball is frozen and jumping is blocked.
Both gates are checked in the input thread BEFORE running the raycast,
so denied jumps don't waste a raycast call.
What changed from v17
v20 changed ground detection from a fixed epsilon to a slope-aware
threshold (radius × 1.45):
On a slope of angle θ, a straight-down raycast hits at distance r/cos(θ)
from the ball center, not r. Using radius * 1.45 covers slopes up to
45° (cos(45°) ≈ 0.707, r/0.707 ≈ 1.414r). The 1.45 factor gives a small
safety margin beyond the theoretical minimum of √2 ≈ 1.414.
Features
- Raycast ground detection: Casts a ray straight down from the ball
position using the game's ownMesh_FindClosestCollision(0x465D90).
If the hit point is withinradius × 1.45of the ball Y, the ball is
grounded and can jump. - Countdown gating: No jumping during Ready/Set/Go countdown
(Scene+0x3A4C == 0). - Race-end gating: No jumping after touching the goal
(ball+0x14C == 1). - Airborne denial: Can't jump while in the air (raycast misses).
- Edge detection: SPACE uses rising-edge detection (one jump per keypress).
- Safety checks: Won't jump if ball pointer not yet captured or
during fall/respawn.
Hook points
| Hook | Address | Original bytes | Purpose |
|---|---|---|---|
| Phase 15 cave | 0x407BB4 |
8B 4C 24 1C 8B 11 (6 bytes) |
Jump impulse application |
The input thread polls the keyboard (DIK_SPACE at KeyboardDevice+0x45)
every 16ms. On rising-edge keypress, it checks countdown/race-end gates,
then runs the raycast. If grounded, sets g_want_jump=1. The Phase 15
cave checks this flag and adds an upward impulse to ball+0x174 (Y force
accumulator) if set.
Files
jump_mod_raycast.c— C source code (BASS proxy + raycast + gates)bass.dll— Compiled DLL (MinGW cross-compiled, PE32 i386)jump_mod_v22.zip— Distribution archive
Build
i686-w64-mingw32-gcc -shared -o bass.dll jump_mod_raycast.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
🔗 Related Documents
Key Function Decompilations
Hamsterball - Key Function Decompilations
Binary: Hamsterball.exe (MD5: 7d25019366b8d7f55906325bd630d7fe)
3,958 functions found by Ghidra auto-analysis
WinMain (0x004278E0)
Entry point. Creates the global App singleton, initializes, runs game loop, shuts down.
undefined4 WinMain(undefined4 param_1,undefined4 param_2,undefined4 param_3)
{
App_Initialize_Full(&DAT_004fd680,param_1,param_3);
App_Run((int *)&DAT_004fd680);
App_Shutdown((int *)&DAT_004fd680);
return 0;
}
App_Initialize_Full (0x00429530) - The 26-Step Init Sequence
Called by WinMain. Sets up the entire game in 26 labeled steps.
void __thiscall App_Initialize_Full(void *this,undefined4 param_1,undefined4 param_2)
{
int iVar1;
int *piVar2;
undefined4 uVar3;
undefined4 *puVar4;
uint uVar5;
void *pvVar6;
void *pvVar7;
void *pvStack_c;
undefined1 *puStack_8;
undefined4 uStack_4;
uStack_4 = 0xffffffff;
puStack_8 = &LAB_004caeec;
pvStack_c = ExceptionList;
ExceptionList = &pvStack_c;
*(char **)((int)this + 0x208) = "Initialize(1)";
App_Initialize(this,param_1,param_2);
*(undefined1 *)(*(int *)((int)this + 0x174) + 0x7d1) = 1;
pvVar7 = *(void **)((int)this + 4);
*(char **)((int)this + 0x208) = "Initialize(3)";
uVar3 = LoadCursorA(pvVar7,"BLANKCURSOR");
*(undefined4 *)((int)this + 0x240) = uVar3;
*(char **)((int)this + 0x208) = "Initialize(4)";
(**(code **)(*(int *)this + 0x8c))(800,600);
iVar1 = *(int *)((int)this + 0x174);
*(char **)((int)this + 0x208) = "Initialize(5)";
if (iVar1 == 0) {
*(char **)((int)this + 0x208) = "** No Graphics **";
}
if (*(int *)(iVar1 + 0x154) == 0) {
*(char **)((int)this + 0x208) = "** No Graphics Device **";
}
if (iVar1 != 0) {
piVar2 = *(int **)(iVar1 + 0x154);
if (*(char *)(iVar1 + 0x7d2) == '\0') {
(**(code **)(*piVar2 + 200))(piVar2,0x16,3);
}
else {
(**(code **)(*piVar2 + 200))(piVar2,0x16,2);
}
*(undefined4 *)(iVar1 + 0x708) = 3;
}
*(char **)((int)this + 0x208) = "Initialize(6)";
puVar4 = FUN_00455c50(*(void **)((int)this + 0x174),"shadow.png",'\x01');
*(undefined4 **)((int)this + 0x278) = puVar4;
*(int *)((int)this + 0x1cc) = *(int *)((int)this + 0x1cc) + 1;
*(char **)((int)this + 0x208) = "Initialize(7)";
puVar4 = FUN_004743f0(*(void **)((int)this + 0x17c),"music\\music.mo3");
*(undefined4 **)((int)this + 0x534) = puVar4;
*(undefined4 *)((int)this + 0x53c) = 0;
*(undefined4 *)((int)this + 0x538) = 0;
*(char **)((int)this + 0x208) = "Initialize(8)";
if (puVar4 != (undefined4 *)0x0) {
FUN_0046a4d0("jukebox.xml");
*(char **)((int)this + 0x208) = "Initialize(9)";
puVar4 = FUN_0046a3c0(*(int *)((int)this + 0x534));
*(undefined4 **)((int)this + 0x53c) = puVar4;
*(char **)((int)this + 0x208) = "Initialize(10)";
puVar4 = FUN_0046a3c0(*(int *)((int)this + 0x534));
*(undefined4 **)((int)this + 0x538) = puVar4;
*(char **)((int)this + 0x208) = "Initialize(11)";
}
*(char **)((int)this + 0x208) = "Initialize(12)";
FUN_00472ec0(*(int *)((int)this + 0x54));
*(char **)((int)this + 0x208) = "Initialize(13)";
uVar3 = FUN_00473170(*(void **)((int)this + 0x54),"PlayCount");
if ((char)uVar3 == '\0') {
*(undefined4 *)((int)this + 0x914) = 0x14;
}
else {
uVar5 = FUN_00473080(*(void **)((int)this + 0x54),"PlayCount");
*(uint *)((int)this + 0x914) = uVar5;
}
*(undefined1 *)((int)this + 0x200) = 1;
*(char **)((int)this + 0x208) = "Initialize(15)";
puStack_8 = operator_new(0x14);
if (puStack_8 == (void *)0x0) {
pvVar6 = (void *)0x0;
}
else {
pvVar6 = (void *)FUN_0046dfa0(puStack_8,*(undefined4 *)((int)this + 0x180));
}
*(void **)((int)this + 0x550) = pvVar6;
*(char **)((int)this + 0x208) = "Initialize(16)";
FUN_0046dfc0(pvVar6,1);
*(char **)((int)this + 0x208) = "Initialize(17)";
puStack_8 = operator_new(0x14);
if (puStack_8 == (void *)0x0) {
pvVar6 = (void *)0x0;
}
else {
pvVar6 = (void *)FUN_0046dfa0(puStack_8,*(undefined4 *)((int)this + 0x180));
}
*(void **)((int)this + 0x554) = pvVar6;
*(char **)((int)this + 0x208) = "Initialize(18)";
FUN_0046dfc0(pvVar6,2);
*(char **)((int)this + 0x208) = "Initialize(19)";
puStack_8 = operator_new(0x14);
if (puStack_8 == (void *)0x0) {
pvVar6 = (void *)0x0;
}
else {
pvVar6 = (void *)FUN_0046dfa0(puStack_8,*(undefined4 *)((int)this + 0x180));
}
*(void **)((int)this + 0x558) = pvVar6;
*(char **)((int)this + 0x208) = "Initialize(20)";
FUN_0046dfc0(pvVar6,4);
*(char **)((int)this + 0x208) = "Initialize(21)";
puStack_8 = operator_new(0x14);
if (puStack_8 == (void *)0x0) {
pvVar6 = (void *)0x0;
}
else {
pvVar6 = (void *)FUN_0046dfa0(puStack_8,*(undefined4 *)((int)this + 0x180));
}
*(void **)((int)this + 0x55c) = pvVar6;
*(char **)((int)this + 0x208) = "Initialize(22)";
FUN_0046dfc0(pvVar6,5);
*(char **)((int)this + 0x208) = "Initialize(23)";
FUN_00472f30(*(int *)((int)this + 0x54));
*(char **)((int)this + 0x208) = "Initialize(25)";
(**(code **)(*(int *)this + 0xa0))();
*(char **)((int)this + 0x208) = "Initialize(26)";
ExceptionList = pvVar7;
return;
}
App_Initialize (0x0046bb40)
void __thiscall App_Initialize(void *this,undefined4 param_1,undefined4 param_2)
{
uint *puVar1;
undefined4 unaff_retaddr;
*(char **)((int)this + 0x208) = "App::Initialize(1)";
(**(code **)(*(int *)this + 0x94))(param_2);
*(undefined4 *)((int)this + 4) = unaff_retaddr;
*(char **)((int)this + 0x208) = "App::Initialize(2)";
FUN_00472f50(*(int *)((int)this + 0x54));
*(char **)((int)this + 0x208) = "App::Initialize(3)";
(**(code **)(*(int *)this + 0xc))();
*(char **)((int)this + 0x208) = "App::Initialize(4)";
(**(code **)(*(int *)this + 0x1c))();
*(char **)((int)this + 0x208) = "App::Initialize(5)";
(**(code **)(*(int *)this + 0x18))();
*(char **)((int)this + 0x208) = "App::Initialize(6)";
(**(code **)(*(int *)this + 0x30))();
*(char **)((int)this + 0x208) = "App::Initialize(7)";
(**(code **)(*(int *)this + 0x3c))();
*(char **)((int)this + 0x208) = "App::Initialize(8)";
(**(code **)(*(int *)this + 0x34))();
*(char **)((int)this + 0x208) = "App::Initialize(9)";
(**(code **)(*(int *)this + 0x38))();
*(char **)((int)this + 0x208) = "App::Initialize(10)";
(**(code **)(*(int *)this + 0x40))();
*(char **)((int)this + 0x208) = "App::Initialize(11)";
Graphics_Initialize(*(void **)((int)this + 0x174),*(undefined4 *)((int)this + 8),
*(undefined4 *)((int)this + 0x15c),*(undefined4 *)((int)this + 0x160),
*(undefined1 *)((int)this + 0x158));
if (*(char *)(*(int *)((int)this + 0x174) + 0x60) != '\0') {
MessageBoxA(0,
"Hamsterball was not able to initialize DirectX! Hamsterball requires DirectX8.0 or better to run. To download the latest DirectX, visit http://www.microsoft.com/directx"
,"DirectX Initialization Error",0);
FUN_004bbaeb(0);
}
*(char **)((int)this + 0x208) = "App::Initialize(12)";
if (*(uint **)((int)this + 0x1b4) != (uint *)0x0) {
puVar1 = FUN_004bacc0(*(uint **)((int)this + 0x1b4),'-');
if (puVar1 != (uint *)0x0) {
FUN_00454000(*(void **)((int)this + 0x174),(char *)((int)puVar1 + 1));
}
}
*(char **)((int)this + 0x208) = "App::Initialize(Ok)";
return;
}
App_Run (0x0046bd80)
void __fastcall App_Run(int *param_1)
{
char cVar1;
int iVar2;
uint uVar3;
int iVar4;
int local_78;
int local_74;
int local_70;
undefined1 auStack_6c [28];
undefined4 local_50 [17];
void *pvStack_c;
undefined1 *puStack_8;
undefined4 local_4;
local_4 = 0xffffffff;
puStack_8 = &LAB_004cd5f8;
pvStack_c = ExceptionList;
ExceptionList = &pvStack_c;
FUN_00457ad0(local_50);
local_70 = (int)(1000 / (longlong)param_1[0x5c]);
local_4 = 0;
local_74 = 0;
local_78 = 0;
iVar2 = GetTickCount();
param_1[0x59] = iVar2;
cVar1 = *(char *)((int)param_1 + 0x159);
while (cVar1 == '\0') {
iVar4 = 0;
Sleep(0);
param_1[0x84] = (int)"Background";
param_1[0x5a] = (int)(1000 / (longlong)param_1[0x5b]);
iVar2 = GetTickCount();
if (DAT_005341e4 < iVar2) {
if ((char)param_1[0x6b] == '\x01') {
FUN_004bae43((char *)(param_1 + 0x66),&DAT_004d03f8);
}
param_1[0x65] = 0;
iVar2 = GetTickCount();
DAT_005341e4 = iVar2 + 1000;
}
iVar2 = PeekMessageA(&local_70,0,0,0,1);
while (iVar2 != 0) {
if (*(char *)((int)param_1 + 0x159) != '\0') goto LAB_0046bfc3;
TranslateMessage(auStack_6c);
DispatchMessageA(&local_70);
iVar2 = PeekMessageA(&local_74,0,0,0,1);
}
if (*(char *)((int)param_1 + 0x159) != '\0') break;
do {
uVar3 = GetTickCount();
if (((int)(uVar3 - param_1[0x59]) < param_1[0x5a] + -5) ||
(local_78 = local_78 + 1, 9 < local_78)) {
param_1[0x84] = (int)&DAT_004d9584;
local_78 = 0;
if ((uint)(local_70 + -5 + local_74) < uVar3) {
if (((void *)param_1[0x5d] != (void *)0x0) &&
((*(char *)((int)param_1 + 0x15a) != '\0' || ((char)param_1[0x56] == '\0')))) {
param_1[0x65] = param_1[0x65] + 1;
FUN_00453b50((void *)param_1[0x5d],(int)local_50);
(**(code **)(*param_1 + 0x24))();
(**(code **)(*param_1 + 0x28))();
(**(code **)(*param_1 + 0x2c))();
FUN_00455a90((void *)param_1[0x5d],'\x01');
}
local_74 = GetTickCount();
}
break;
}
param_1[99] = param_1[99] + 1;
param_1[0x84] = (int)"Update";
FUN_00453b50((void *)param_1[0x5d],(int)local_50);
(**(code **)(*param_1 + 0x20))();
iVar2 = param_1[0x59];
param_1[0x59] = iVar2 + param_1[0x5a];
if (1000 < (int)(uVar3 - (iVar2 + param_1[0x5a]))) {
param_1[0x59] = uVar3 - 1000;
}
iVar4 = iVar4 + 1;
} while (iVar4 < 1);
cVar1 = *(char *)((int)param_1 + 0x159);
}
LAB_0046bfc3:
local_4 = 0xffffffff;
FUN_00457a40(local_50);
ExceptionList = pvStack_c;
return;
}
Graphics_Initialize (0x00455380)
/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */
void __thiscall
Graphics_Initialize(void *this,undefined4 param_1,undefined4 param_2,undefined4 param_3,
undefined1 param_4)
{
int *piVar1;
int iVar2;
undefined4 uVar3;
uint uVar4;
int iVar5;
uint uVar6;
undefined4 *puVar7;
undefined4 *puVar8;
undefined4 *puVar9;
undefined1 *puVar10;
int **ppiVar11;
undefined4 uStack_17c;
undefined4 uStack_178;
int *piStack_174;
int *piStack_164;
char *pcStack_160;
int *piStack_154;
char *pcStack_150;
int iStack_14c;
undefined4 uStack_148;
int iStack_138;
int aiStack_134 [2];
int iStack_12c;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(1)";
*(undefined4 *)((int)this + 0x6c) = param_1;
*(undefined4 *)((int)this + 0x70) = param_2;
*(undefined1 *)((int)this + 0x78) = param_4;
iVar5 = 0;
*(undefined4 *)((int)this + 0x7c4) = 0;
*(undefined4 *)((int)this + 0x74) = param_3;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(2)";
uStack_148 = 0x4553d8;
FUN_00453ed0((int)this);
uStack_148 = 0xdc;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(3)";
iStack_14c = 0x4553ef;
iVar2 = Direct3DCreate8();
*(int *)((int)this + 0x7c) = iVar2;
if (iVar2 == 0) {
iStack_14c = 0;
pcStack_150 = "DirectX Error";
piStack_154 = (int *)0x4d8d78;
MessageBoxA();
*(undefined1 *)((int)this + 0x60) = 1;
return;
}
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(4)";
piStack_154 = *(int **)((int)this + 0x7c);
iStack_14c = (int)this + 0x158;
pcStack_150 = (char *)0x0;
iVar2 = (**(code **)(*piStack_154 + 0x20))();
if (iVar2 < 0) {
pcStack_160 = "Failed: GetAdapterDisplayMode(D3DADAPTER_DEFAULT,&mDesktopMode)";
piStack_164 = (int *)0x0;
MessageBoxA();
*(undefined1 *)((int)this + 0x60) = 1;
return;
}
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(5)";
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(6)";
piStack_164 = *(int **)((int)this + 0x7c);
pcStack_160 = (char *)0x0;
uVar6 = 0;
uVar4 = 0;
piStack_154 = (int *)(**(code **)(*piStack_164 + 0x18))();
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(7)";
*(undefined4 *)((int)this + 0x17c) = 0x14;
*(undefined4 *)((int)this + 0x178) = 0x17;
*(undefined1 *)((int)this + 0x7d3) = 0;
*(undefined1 *)((int)this + 0x7d4) = 0;
*(undefined1 *)((int)this + 0x7d5) = 0;
*(undefined1 *)((int)this + 0x7d6) = 0;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(8)";
if (0 < (int)piStack_154) {
do {
piStack_174 = *(int **)((int)this + 0x7c);
uStack_178 = 0x4554e7;
iVar2 = (**(code **)(*piStack_174 + 0x1c))();
if (-1 < iVar2) {
if (iStack_138 == 0x280) {
if (aiStack_134[0] == 0x1e0) {
*(undefined1 *)((int)this + 0x7d3) = 1;
}
}
else if (iStack_138 == 800) {
if (aiStack_134[0] == 600) {
*(undefined1 *)((int)this + 0x7d4) = 1;
}
}
else if (iStack_138 == 0x400) {
if (aiStack_134[0] == 0x300) {
*(undefined1 *)((int)this + 0x7d5) = 1;
}
}
else if ((iStack_138 == 0x500) && (aiStack_134[0] == 0x400)) {
*(undefined1 *)((int)this + 0x7d6) = 1;
}
if (iStack_12c == 0x14) {
if (uVar4 < 1000) {
*(undefined4 *)((int)this + 0x17c) = 0x14;
uVar4 = 1000;
}
}
else if (iStack_12c == 0x16) {
if (uVar4 < 900) {
*(undefined4 *)((int)this + 0x17c) = 0x16;
uVar4 = 900;
}
}
else if (iStack_12c == 0x15) {
if (uVar4 < 800) {
*(undefined4 *)((int)this + 0x17c) = 0x15;
uVar4 = 800;
}
}
else if (iStack_12c == 0x17) {
if (uVar6 < 1000) {
*(undefined4 *)((int)this + 0x178) = 0x17;
uVar6 = 1000;
}
}
else if (iStack_12c == 0x18) {
if (uVar6 < 900) {
*(undefined4 *)((int)this + 0x178) = 0x18;
uVar6 = 900;
}
}
else if (iStack_12c == 0x19) {
if (uVar6 < 800) {
*(undefined4 *)((int)this + 0x178) = 0x19;
uVar6 = 800;
}
}
else if ((iStack_12c == 0x1a) && (uVar6 < 700)) {
*(undefined4 *)((int)this + 0x178) = 0x1a;
uVar6 = 700;
}
}
iVar5 = iVar5 + 1;
} while (iVar5 < (int)piStack_154);
}
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(9)";
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(10)";
if (*(char *)((int)this + 0x18c) == '\0') {
uVar3 = *(undefined4 *)((int)this + 0x17c);
}
else {
uVar3 = *(undefined4 *)((int)this + 0x178);
}
*(undefined4 *)((int)this + 0x174) = uVar3;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(11)";
puVar9 = (undefined4 *)((int)this + 0x194);
puVar7 = puVar9;
for (iVar2 = 0xd; iVar2 != 0; iVar2 = iVar2 + -1) {
*puVar7 = 0;
puVar7 = puVar7 + 1;
}
*(undefined4 *)((int)this + 0x1ac) = *(undefined4 *)((int)this + 0x6c);
*(undefined4 *)((int)this + 0x1b0) = 1;
*puVar9 = *(undefined4 *)((int)this + 0x70);
*(undefined4 *)((int)this + 0x1a8) = 1;
*(undefined4 *)((int)this + 0x1b4) = 1;
*(undefined4 *)((int)this + 0x198) = *(undefined4 *)((int)this + 0x74);
*(undefined4 *)((int)this + 0x19c) = *(undefined4 *)((int)this + 0x164);
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(12)";
piStack_174 = (int *)0x4556e4;
FUN_00453c90(this,*(undefined4 **)((int)this + 0x164),&piStack_154,
(undefined1 *)((int)this + 0x180));
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(13)";
*(int **)((int)this + 0x1b8) = piStack_154;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(14)";
puVar7 = (undefined4 *)((int)this + 0x1c8);
puVar8 = puVar7;
for (iVar2 = 0xd; iVar2 != 0; iVar2 = iVar2 + -1) {
*puVar8 = 0;
puVar8 = puVar8 + 1;
}
*(undefined4 *)((int)this + 0x1cc) = *(undefined4 *)((int)this + 0x74);
*(undefined4 *)((int)this + 0x1d0) = *(undefined4 *)((int)this + 0x174);
*puVar7 = *(undefined4 *)((int)this + 0x70);
*(undefined4 *)((int)this + 0x1d4) = 1;
*(undefined4 *)((int)this + 0x1d8) = 0;
*(undefined4 *)((int)this + 0x1e4) = 0;
*(undefined4 *)((int)this + 0x1e8) = 1;
*(undefined4 *)((int)this + 0x1e0) = *(undefined4 *)((int)this + 0x6c);
*(undefined4 *)((int)this + 0x1dc) = 2;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(15)";
piStack_174 = (int *)0x45578c;
FUN_00453c90(this,*(undefined4 **)((int)this + 0x174),&piStack_154,
(undefined1 *)((int)this + 0x181));
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(16)";
*(int **)((int)this + 0x1ec) = piStack_154;
*(undefined4 *)((int)this + 500) = 0;
*(undefined4 *)((int)this + 0x1f8) = 1;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(17)";
if (*(char *)((int)this + 0x78) == '\x01') {
*(undefined4 **)((int)this + 400) = puVar7;
*(undefined1 *)((int)this + 0x182) = *(undefined1 *)((int)this + 0x181);
}
else {
*(undefined4 **)((int)this + 400) = puVar9;
*(undefined1 *)((int)this + 0x182) = *(undefined1 *)((int)this + 0x180);
}
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(18)";
piVar1 = (int *)((int)this + 0x154);
piStack_174 = *(int **)((int)this + 0x6c);
uStack_178 = 1;
uStack_17c = 0;
(**(code **)(**(int **)((int)this + 0x7c) + 0x3c))(*(int **)((int)this + 0x7c));
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(19)";
if (*piVar1 == 0) {
(**(code **)(**(int **)((int)this + 0x7c) + 0x3c))
(*(int **)((int)this + 0x7c),0,1,*(undefined4 *)((int)this + 0x6c),0x80,
*(undefined4 *)((int)this + 400),piVar1);
if (*piVar1 == 0) {
iVar2 = (**(code **)(**(int **)((int)this + 0x7c) + 0x3c))
(*(int **)((int)this + 0x7c),0,1,*(undefined4 *)((int)this + 0x6c),0x20,
*(undefined4 *)((int)this + 400),piVar1);
if (*piVar1 == 0) {
FUN_004764f0(iVar2,(char *)aiStack_134,0xff);
MessageBoxA(0,aiStack_134,"Graphics::Initialize",0);
*(undefined1 *)((int)this + 0x60) = 1;
return;
}
}
}
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(20)";
FUN_0042c810(this);
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(21)";
(**(code **)(**(int **)((int)this + 0x7c) + 0x34))
(*(int **)((int)this + 0x7c),0,1,(int)this + 0x80);
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(22)";
FUN_00454f10(this,0,0);
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(23)";
FUN_00454ab0(this,10.0,5000.0);
piStack_164 = (int *)0x3f800000;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(24)";
pcStack_160 = (char *)0x0;
piStack_154 = (int *)0x0;
uStack_17c = 0;
pcStack_150 = (char *)0x0;
ppiVar11 = &piStack_164;
iStack_14c = 0;
uStack_178 = 0;
puVar10 = &stack0xfffffe90;
piStack_174 = (int *)0x0;
puVar9 = &uStack_17c;
iVar2 = (int)this + 0x1fc;
thunk_FUN_0045c01a();
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(25)";
FUN_00454630(this);
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(26)";
(**(code **)(*(int *)*piVar1 + 0xfc))
((int *)*piVar1,0,0x13,*(float *)((int)this + 0x184) * _DAT_004cf41c - _DAT_004cf48c,
iVar2,puVar9,puVar10,ppiVar11);
*(int *)((int)this + 0x7c8) = *(int *)((int)this + 0x7c8) + 1;
*(int *)((int)this + 0x184) = iVar2;
*(char **)(*(int *)((int)this + 0x5c) + 0x208) = "Graphics::Initialize(27)";
return;
}
LoadMeshWorld (0x0045de30)
undefined4 __thiscall LoadMeshWorld(void *this,char *param_1)
{
code *pcVar1;
int iVar2;
undefined4 uVar3;
void *this_00;
undefined4 *this_01;
char local_10c [248];
void *pvStack_14;
void *pvStack_c;
undefined1 *puStack_8;
undefined4 local_4;
local_4 = 0xffffffff;
puStack_8 = &LAB_004cceae;
pvStack_c = ExceptionList;
ExceptionList = &pvStack_c;
FUN_004bae43(local_10c,(byte *)"%s.meshworld");
iVar2 = FUN_004c8ff7((uint)local_10c);
if (iVar2 == 0) {
(**(code **)(*(int *)this + 0x38))(local_10c);
uVar3 = (**(code **)(*(int *)this + 0x3c))(0);
}
else {
this_00 = operator_new(0x488);
local_4 = 0;
if (this_00 == (void *)0x0) {
this_01 = (undefined4 *)0x0;
}
else {
this_01 = FUN_004706e0(this_00,*(undefined4 *)((int)this + 4));
}
local_4 = 0xffffffff;
*(undefined4 **)((int)this + 8) = this_01;
*(undefined1 *)((int)this + 0xd) = 1;
uVar3 = FUN_00470930(this_01,param_1,'\0');
if ((char)uVar3 != '\x01') {
MessageBoxA(0,param_1,"COULD NOT LOAD",0);
FUN_004bbaeb(0);
pcVar1 = (code *)swi(3);
uVar3 = (*pcVar1)();
return uVar3;
}
*(undefined1 *)((int)this + 0xc) = 1;
uVar3 = (**(code **)(*(int *)this + 4))();
}
ExceptionList = pvStack_14;
return CONCAT31((int3)((uint)uVar3 >> 8),1);
}
CameraLookAt (0x00413280)
void __fastcall CameraLookAt(int *param_1)
{
int iVar1;
int iVar2;
int iVar3;
void *pvVar4;
undefined4 *puVar5;
int *piVar6;
undefined4 auStack_20 [3];
void *pvStack_14;
void *pvStack_c;
undefined1 *puStack_8;
undefined4 local_4;
local_4 = 0xffffffff;
puStack_8 = &LAB_004c97a6;
pvStack_c = ExceptionList;
ExceptionList = &pvStack_c;
pvVar4 = operator_new(0x10d0);
local_4 = 0;
if (pvVar4 == (void *)0x0) {
puVar5 = (undefined4 *)0x0;
}
else {
puVar5 = FUN_00461510(pvVar4,*(undefined4 *)(param_1[0x21e] + 0x174),
"levels\\arena-spawnplatform");
}
local_4 = 0xffffffff;
param_1[0x10e3] = (int)puVar5;
pvVar4 = operator_new(0x10d0);
local_4 = 1;
if (pvVar4 == (void *)0x0) {
puVar5 = (undefined4 *)0x0;
}
else {
puVar5 = FUN_00461510(pvVar4,*(undefined4 *)(param_1[0x21e] + 0x174),"levels\\arena-stands");
}
param_1[0x10e4] = (int)puVar5;
local_4 = 0xffffffff;
(**(code **)(*param_1 + 0x90))(param_1[0x10e3]);
(**(code **)(*param_1 + 0x90))(param_1[0x10e4]);
FUN_0040b090((int)param_1);
piVar6 = (int *)FUN_004605e0((void *)param_1[0x22b],auStack_20,"CAMERALOOKAT",(undefined1 *)0x0);
iVar1 = piVar6[1];
iVar3 = *piVar6;
iVar2 = piVar6[2];
param_1[0x10de] = iVar3;
param_1[0x10df] = iVar1;
param_1[0x10e0] = iVar2;
param_1[0x10db] = iVar3;
param_1[0x10dc] = iVar1;
param_1[0x10dd] = iVar2;
param_1[0xa6f] = 0x42340000;
param_1[0xa70] = 0x44480000;
param_1[0x10e1] = 0x44480000;
param_1[0x10e2] = 1;
(**(code **)(*param_1 + 0x54))();
ExceptionList = pvStack_14;
return;
}
ESellerate_Init (0x00429200)
void __fastcall ESellerate_Init(int param_1)
{
int iVar1;
if (*(char *)(param_1 + 0x218) == '\0') {
*(undefined1 *)(param_1 + 0x218) = 1;
iVar1 = FUN_00473355();
if (iVar1 == 1) {
MessageBoxA(0,"Count not install eSellerate Engine!",&DAT_004d2908,0);
}
}
return;
}
App_Shutdown (0x0046ba10)
void __fastcall App_Shutdown(int *param_1)
{
*(undefined1 *)((int)param_1 + 0x159) = 1;
/* WARNING: Could not recover jumptable at 0x0046ba19. Too many branches */
/* WARNING: Treating indirect jump as call */
(**(code **)(*param_1 + 8))();
return;
}
🔗 Related Documents
Level [[53892486924051|collision system]]
Hamsterball Level Collision System
This document describes the level-side collision classes used by the game’s static mesh raycasts. It is distinct from the ball’s CollisionMesh physics body (see COLLISIONMESH_OBJECT_MODDING.md).
All offsets and addresses are from the original Windows Hamsterball.exe (image base 0x00400000).
Overview
When a level is loaded, the game creates two objects from the .MW file:
| Scene Offset | Field | Type | Purpose |
|---|---|---|---|
+0x8AC |
source_meshworld_ptr |
MeshWorld* |
Render/source level geometry, loaded by MeshWorld_ctor |
+0x8B0 |
collision_level_ptr |
CollisionLevel* (or Level*) |
Collision-only copy of the level, used by Mesh_FindClosestCollision |
Both pointers are filled by Scene_LoadLevel2 / Scene_LoadLevel3 etc. and finalized by Level_InitScene. The collision pointer (+0x8B0) is the this pointer you hook in Mesh_FindClosestCollision.
Class Hierarchy
SceneObject (base)
└── Level / MeshWorld
├── +0x8 MeshWorld* inner_mesh_world (copy of source)
├── +0x480 SpatialTree* root_spatial_tree (acceleration structure)
└── inherited AthenaLists for buffers / objects
CollisionLevel = Level variant produced by CollisionLevel_ctorWithLevel
its vtable is at 0x004D9068.
Important distinction
MeshWorldcontains the render-side vertex buffers, materials, and object lists.CollisionLevelis a separate level instance that mirrors the geometry but is optimized for collision queries (spatial tree, NOCOLLIDE handling,(NOCOLLIDE)tag stripping, etc.).- You almost never want to touch the source
MeshWorldfor physics; raycasts useScene + 0x8B0.
CollisionLevel / Level Struct Layout
The class is loaded through MeshWorld_ctor and CollisionLevel_ctorWithLevel. alloc size is 0x10d0 (4304) bytes.
| Offset | Type | Field | Notes |
|---|---|---|---|
+0x000 |
void** |
vtable |
0x004D9068 for CollisionLevel; 0x004D8FB0 for plain Level |
+0x004 |
int |
ref_count |
Standard SceneObject refcount |
+0x008 |
MeshWorld* |
inner_meshworld |
Points to the internal copy of the render mesh. Collision_TraverseSpatialTree reads this + 0x8 → +0x2c (mesh buffer list). |
+0x00C |
int |
owner_id |
Usually holds the owning App* passed to Level_ctor. |
+0x018 |
AthenaList |
child_levels |
List of subdivided child levels (used by Scene_SubdivideRandom). |
+0x430 |
byte |
has_subdivided |
Set to 1 if the level was spatially subdivided. |
+0x431 |
byte |
use_subdivision |
Subdivision active flag. |
+0x434 |
uint32_t |
level_index |
Level number / index. |
+0x438 |
Timer |
update_timer |
Standard Timer object. |
+0x47C |
void* |
owner_app |
Back-pointer to the App instance. |
+0x480 |
SpatialTree* |
root_spatial_tree |
Root of the static spatial acceleration tree (size 0x10d0). |
+0x484 |
byte |
build_spatial_tree |
If 1, a spatial tree was built for collision. |
+0x488 |
AthenaList |
render_contexts |
RenderContext list. |
+0x8A0 |
AthenaList |
mesh_buffer_list2 |
Secondary buffer list. |
+0xCB8 |
AthenaList |
object_list |
Level objects such as START1-1, E:JUMP, etc. |
Note: Many internal lists are
AthenaList(12-byte embedded structure: head index + size + data vector). Do not write raw pointers into them.
The SpatialTree
SpatialTree is the actual acceleration structure used for ray-triangle tests. It is allocated at 0x10d0 bytes and stored in the Level at +0x480.
SpatialTree vtable: 0x004D9038
| Slot | Address | Notes |
|---|---|---|
| 0 | 0x004633B0 |
SpatialTree_DeletingDtor |
| 1 | 0x004633B0? |
destructor path |
| 2+ | traversal / build slots | Collision_TraverseSpatialTree uses child lists |
SpatialTree notable fields
| Offset | Type | Field | Default | Notes |
|---|---|---|---|---|
+0x00 |
void** |
vtable |
0x004D9038 |
|
+0x04 |
void* |
owner |
list or Level* | Used by CollisionNode_BaseInit |
+0x08 |
void* |
meshworld |
— | Used to reach mesh buffers (+0x2c) |
+0x0C |
float |
leaf_size |
0.1f |
Cell/target size for tree subdivision |
+0x10 |
int |
max_depth |
6 |
Max spatial-tree recursion |
+0x14 |
float |
scale |
0.9f |
Bounding-box scale factor |
+0x18–+0x1E |
byte[7] |
axis_flags |
all 1 |
Which axes are active for subdivision |
Collision_TraverseSpatialTree recursively walks the child list at +0x18, tests triangles from each MeshBuffer against an AABB, and appends candidate triangles to a temporary collision mesh.
How to Access an Instance
From the global App
The easiest stable pointer is the global App object at 0x004FD680:
App* app = (App*)0x004FD680;
void* scene = *(void**)((char*)app + 0x878); // App->active_scene
void* collision_level = *(void**)((char*)scene + 0x8B0);
From the Ball
void* ball = ...;
void* scene = *(void**)((char*)ball + 0x14); // Ball->scene
void* collision_level = *(void**)((char*)scene + 0x8B0);
From a Mesh_FindClosestCollision hook
The this pointer in ECX is exactly Scene + 0x8B0:
using Mesh_FindClosestCollision_t =
Vec3* (__thiscall*)(void* collision_level, Vec3* out,
Vec3 origin, Vec3 direction, float max_dist);
Mesh_FindClosestCollision_t Original =
(Mesh_FindClosestCollision_t)0x00465D90;
Key Functions
| Address | Name | Role |
|---|---|---|
0x00461510 |
MeshWorld_ctor |
Loads/references a .MW level and creates the source MeshWorld. |
0x00465080 |
CollisionLevel_ctorWithLevel |
Creates a collision-only Level from a source MeshWorld. Sets vtable 0x004D9068; calls Level_LoadMeshes. |
0x00465860 |
Level_LoadMeshes |
Builds the internal MeshWorld copy and collision geometry for a Level. |
0x00465D90 |
Mesh_FindClosestCollision |
Main raycast API; builds a temporary spatial tree over the level and casts a ray. See How max_dist Actually Works below. |
0x00403980 |
Ball_FindMeshCollision |
Thin wrapper around Mesh_FindClosestCollision; rarely called by the engine directly. |
0x00465EF0 |
Collision_TraverseSpatialTree |
Recursive triangle-collection function inside Mesh_FindClosestCollision. |
0x00463330 |
SpatialTree_ctor |
Initializes an axis-aligned spatial subdivision node. |
0x0040D280 |
Scene_LoadLevel2 |
Hard-coded loader for Level 2; allocates MeshWorld at +0x8AC and CollisionLevel at +0x8B0. |
0x0040B090 |
Level_InitScene |
Finishes scene setup after both level instances are created; reads scene + 0x8AC and +0x8B0. |
0x00456D80 |
CollisionMesh_ctor |
Creates the temporary CollisionMesh used inside a single raycast (not persisted). |
Vtables
CollisionLevel / Level vtable: 0x004D9068
| Slot | Address | Logical method |
|---|---|---|
| 0 | 0x00465240 |
Level_DeletingDtor2 |
| 1 | 0x004606D0 |
(render / strip builder) |
| 2 | 0x00472770 |
SceneObject_BuildStrips |
| 3 | 0x00471750 |
LoadMesh |
| 4 | 0x00470440 |
(render utility) |
| 5 | 0x0046F3B0 |
(render utility) |
| 6 | 0x00470150 |
SceneObject_RenderFull |
| 7 | 0x0046F390 |
(render utility) |
| 8 | 0x00471830 |
utility / mesh preprocessor |
| 9 | 0x0045DFD0 |
SceneObject_CallRender variant |
| 10 | 0x00461370 |
MeshWorld_CollectTrianglesInAABB related |
| 11 | 0x0044ACB0 |
SceneObject_CallUpdate variant |
| 12 | 0x00465100 |
Level_LoadCollision / clone helper |
| 13 | 0x00461890 |
Scene_LoadMeshWorld |
| 14 | 0x004651D0 |
Level_dtor helper |
| 15 | 0x00460DA0 |
Scene_RenderFrame related |
| 16 | 0x00461F00 |
Scene_Subdivide |
| 17 | 0x00462100 |
Scene_SubdivideRandom |
| 18 | 0x00465650 |
Level_CloneTree related |
CollisionMesh (temporary) vtable: 0x004D8E10
This is the temporary collision buffer used inside Mesh_FindClosestCollision, not the level class itself. Listed here because the same name appears in the code path.
| Slot | Address | Method |
|---|---|---|
| 0 | 0x00456870 |
Mesh_DeletingDtor |
| 1 | 0x004564C0 |
Ball_AdvancePositionOrCollision |
| 5 | 0x00456120 |
CollisionMesh_AddTriangle |
| 8 | 0x00457A20 |
helper |
| 19 | 0x00458200 |
helper |
| 22 | 0x0045DF20 |
traversal helper |
| 23 | 0x00458A40 |
Scene_ScalarDtorBase path |
Hooking Example: Ground Raycast Using the Collision Level
struct Vec3 { float x, y, z; };
using Mesh_FindClosestCollision_t =
Vec3* (__thiscall*)(void* collision_level,
Vec3* out,
Vec3 origin,
Vec3 direction,
float max_dist);
static auto Mesh_FindClosestCollision =
(Mesh_FindClosestCollision_t)0x00465D90;
bool IsGroundedAt(float x, float y, float z, void* scene, float radius) {
void* collision_level = *(void**)((char*)scene + 0x8B0);
if (!collision_level) return false;
Vec3 origin = { x, y, z };
Vec3 down = { 0.0f, -1.0f, 0.0f };
Vec3 out = { x, y - 1000.0f, z };
float max_dist = radius + 0.5f;
Mesh_FindClosestCollision(collision_level, &out, origin, down, max_dist);
float dy = origin.y - out.y;
return (dy > 0.001f && dy < max_dist);
}
Note: The
max_distcheck in the return line above (dy < max_dist) is a caller-side filter. The function itself does NOT use max_dist as a distance limit — see below.
How max_dist Actually Works
Verified by full decompilation chain on 2026-06-17. Traced through 5 functions.
max_dist is the collision sphere radius — it controls how wide the AABB broad-phase query is around the ray. It is NOT a distance limit on the ray itself (the ray is always ~994 units long after clamping).
Full call chain
Step 1 — Mesh_FindClosestCollision (0x465D90):
- Creates a temp
CollisionMeshand callsBall_InitBattleModewhich sets:+0xC68(friction) =0.555+0xC70(max_speed) =1000.0+0xC7C(use_collision_callback) =1+0xC8C(gravity2 vector) =(0, -1.0, 0)BUT+0xC64(scale) =0.0→ no gravity
- Scales direction to 99999 units:
Vec3_NormalizeAndScale(&direction, 99999.0) - Packs
max_distintoVec3(d, d, d) - Calls
Ball_AdvancePositionOrCollision(mesh, out, origin, &dir_99999, &max_dist_vec, 0.01)
Step 2 — Ball_AdvancePositionOrCollision (0x4564C0):
- Velocity starts at
(0,0,0), addsdir_99999→ velocity = 99999 units - Clamps to
max_speed=1000:Vec3_NormalizeAndScale(&velocity, 1000.0)→ effective ray = 1000 units - Friction damping:
velocity *= (1.0 - 0.01) + (1.0 - 0.555) * 0.01 = 0.99445→ ~994 units - Gravity:
0.01 * scale(0.0) = 0.0→ no gravity applied use_callback=1→ callsvtable[7](0x456890):callback(buf, origin, velocity, max_dist_vec, 0.01, &hit_flag)
Step 3 — Collision callback (0x456890):
- Computes
magnitude = sqrt(max_dist² × 3) = max_dist × √3 - If
magnitude < 0.0001: return origin (no collision — max_dist too small) - Calls
AABB_FromSphere(origin, velocity, max_dist_vec, &min_bounds, &max_bounds)
Step 4 — AABB_FromSphere (0x477330):
- Computes the axis-aligned bounding box of the swept sphere:
min = min(origin, origin+velocity) - max_dist - 0.01 max = max(origin, origin+velocity) + max_dist + 0.01 max_distis the sphere radius that expands the AABB on all axes- The spatial tree then returns only triangles that intersect this AABB
Step 5 — Back in callback:
- Normalizes space: divides origin and velocity by
max_dist(sphere radius → 1.0) - Does sphere-vs-triangle intersection in normalized space
- Scales hit point back by
max_distto get world-space result
What this means in practice
The AABB is a box from origin to origin + velocity (the ray), expanded by max_dist on all sides. The spatial tree only returns triangles within this box.
| max_dist | AABB width (perpendicular to ray) | Effect |
|---|---|---|
39 |
39 units | Wide box → captures floor 26 units below a horizontal ray |
5 |
5 units | Tight box → floor excluded → only nearby walls returned |
Your max_dist should be the ball's collision radius (or slightly larger). The game uses radius + 0.5f. Using a large value like 39 causes the AABB to include geometry far off-axis from the ray direction.
The 0.01 parameter
The 0.01 (param_5) is a physics damping/timestep factor:
fVar3 = (1.0 - 0.01) + (1.0 - friction) * 0.01; // velocity damping
fVar3 = 0.01 * scale; // gravity scaling (scale=0 → no gravity)
It is NOT a sphere radius and NOT a distance limit.
Summary
| Parameter | What it actually does |
|---|---|
direction |
Normalized → 99999 → clamped to 1000 → damped to ~994. This is the ray length. |
max_dist |
Sphere radius for AABB broad-phase. Expands the bounding box perpendicular to the ray. Larger = wider query = more triangles tested. Use ball_radius + 0.5f. |
0.01 (internal) |
Physics damping factor. Not accessible to callers. |
Practical implications
- To detect walls: use
max_dist= ball radius (e.g., 10). The AABB will be tight enough to exclude floor geometry when casting horizontally. - To detect ground: cast downward with the same
max_dist. The AABB will include the floor naturally. - To limit search range: check
distance(origin, out)after the call. The ray is always ~994 units long. - Too-large max_dist: includes off-axis geometry in the AABB, causing false hits (like the floor 26 units below when casting horizontally with max_dist=39).
Modding Notes
- Do not free or replace
Scene + 0x8B0. It owns triangle data and child spatial-tree memory used by the physics tick. - The spatial tree is rebuilt per raycast in
Mesh_FindClosestCollision. The level pointer stored at+0x8B0is stable; the heavy work is done insideCollision_TraverseSpatialTree. Scene + 0x8ACis the sourceMeshWorldused for rendering and object lookup;Scene + 0x8B0is the collision-only copy. Modifying+0x8ACgeometry will not affect physics unless you regenerate+0x8B0.CollisionLevel_PlayBreakSound(0x00435B00) andCollisionLevel_Spatial_Ctor(0x00436250) are arena/break-specific branches; normal race levels use the standard collision path.
Sources
All data verified from live GhidraMCP headless decompilation of Hamsterball.exe:
CollisionLevel_ctorWithLevel@0x00465080Level_LoadMeshes@0x00465860Level_ctor@0x00461740MeshWorld_ctor@0x00461510Mesh_FindClosestCollision@0x00465D90Collision_TraverseSpatialTree@0x00465EF0SpatialTree_ctor/SpatialTree_CloneToLevel@0x00463330/0x00462380Scene_LoadLevel2@0x0040D280Level_InitScene@0x0040B090- Static disassembly of
Hamsterball.exefor field references to+0x8ACand+0x8B0
Document generated: 2026-06-17
🔗 Related Documents
Level Base Color System
Level Base Color System
Overview
Each race/arena level in Hamsterball has a base color — an RGB triplet hardcoded
in the BoardLevel constructor. This color is NOT stored in the MESHWORLD file. It
controls the tint of:
- Timer oval background (
timerblot.pngquad tinted with the level color) - Timer text (the countdown clock numbers, rendered with
showcardgothic72font) - Arena score display oval (same mechanism, different position/size)
When the timer is running low, the G and B channels are zeroed (leaving only R),
making the timer turn red as a visual warning.
Where the Color Is Set
Each BoardLevel*_ctor function calls Vec3_Init with three float constants, then
writes the result to the board struct:
// Example: LevelBoard_WarmUp_ctor (0x0041CA40)
iVar1 = Vec3_Init(local_20, 0x3f800000, 0, 0x3f800000); // R=1.0, G=0.0, B=1.0 = Magenta
*(board + 0x1508) = *(iVar1 + 4); // R
*(board + 0x150C) = *(iVar1 + 8); // G
*(board + 0x1510) = *(iVar1 + 0xC); // B
*(board + 0x1514) = *(iVar1 + 0x10); // A (always 1.0 from Vec3_Init)
Matrix_Identity(local_20);
Board Struct Fields
| Offset | Type | Description |
|---|---|---|
| +0x1508 | float | Base color R |
| +0x150C | float | Base color G |
| +0x1510 | float | Base color B |
| +0x1514 | float | Base color A (always 1.0) |
| +0x4340 | float | Render scale factor (always 1.0, set in Board_ctor at 0x00419030) |
Where the Color Is Used
Timer HUD — Scene_RenderTimerHUD (vtable[0x1C] @ 0x0041BFD0)
This is Scene vtable slot 0x1C (offset 0x70 in vtable), called during the overlay
render pass (after opaque + translucent passes).
1P race mode:
// Read level color from board
R = *(float*)(board + 0x1508);
G = *(float*)(board + 0x150C);
B = *(float*)(board + 0x1510);
Scale = *(float*)(board + 0x4340); // 1.0
// Draw timer oval (timerblot.png quad, tinted with level color)
Scene_CreateObject4f(gfx, &timer_vtable, x, 10.0, 180.0, 105.0,
&Vec3_dtor, R, G, B, Scale);
// Build color matrix for text
Matrix_Scale4x4(&matrix, 1.0, 1.0, 1.0, Scale); // Normal: full color
// Time warning thresholds:
if (time < 0x44C) { // ~1100ms = 1.1 seconds
Matrix_Scale4x4(&matrix, 1.0, 0, 0, Scale); // Zero G,B → RED
}
if (time < 600) { // 600ms = 0.6 seconds
Matrix_Scale4x4(&matrix, 1.0, 0, 0, Scale); // Keep RED
}
// Draw timer text (showcardgothic72 font)
UI_DrawTextCentered(font72, formatted_time, ..., R, G, B, Scale);
// Draw decimal text (showcardgothic28 font, smaller)
UI_DrawTextShadow_Wrapper(font28, ".N", ..., R, G, B, Scale);
2P race mode: Same mechanism, draws two timer displays (one per viewport).
Arena Score HUD — Scene_RenderScoreHUD (vtable[0x1B] @ 0x0041B710)
This is Scene vtable slot 0x1B (offset 0x6C in vtable), called during the post-FX
render pass.
// Arena mode score display also uses the level color
R = *(float*)(board + 0x1508);
G = *(float*)(board + 0x150C);
B = *(float*)(board + 0x1510);
Scale = *(float*)(board + 0x4340);
// Draw score oval (timerblot.png quad, tinted with level color)
Scene_CreateObject4f(gfx, &timer_vtable, 460.0, 17.0, 80.0, 46.67,
&Vec3_dtor, R, G, B, Scale);
// Score text uses WHITE (1,1,1) with level color only on the oval
Matrix_Scale4x4(&matrix, 1.0, 1.0, 1.0, Scale);
Level Name Text — NOT Level-Colored
The level name string (e.g., "WARM-UP RACE") is stored at board+0x29B4 and rendered
in the ScoreHUD with hardcoded white text and a black shadow:
// Background rect: semi-transparent (alpha=0.75)
Matrix_Scale4x4(&matrix, 1.0, 1.0, 0, 0.75);
UI_DrawRectAndReset(gfx);
// Shadow: black (0,0,0,1.0)
// Main text: white (1,1,1,1.0)
Matrix_Scale4x4(&matrix, 1.0, 1.0, 1.0, 1.0);
UI_DrawTextCenteredAbsolute(font28, level_name, x+400, 0x82, 5, 5, ...);
Timer Oval Texture
The oval behind the timer uses timerblot.png (loaded at App+0x390 by
App_ResourceLoader at 0x004298C0). This is a white/neutral texture that gets
tinted by the level color via D3D8 MODULATE blending (texture × vertex diffuse color).
Related textures also loaded but used elsewhere:
blueblot.png(App+0x354) — alternative blot, possibly for 2P indicatorblueblot2.png(App+0x358) — another blot variantbluecircle.png(App+0x35C) — circle overlay
Color Table — Race Levels
| Level | MESHWORLD | Ctor Address | R | G | B | RGB255 | Color |
|---|---|---|---|---|---|---|---|
| Warm-Up | Level1 | 0x0041CA40 | 1.0 | 0.0 | 1.0 | (255,0,255) | Magenta/Pink |
| Intermediate | Level2 | 0x0041CB20 | 0.0 | 0.0 | 1.0 | (0,0,255) | Blue |
| Dizzy | Level3 | 0x0041D060 | 0.0 | 1.0 | 0.0 | (0,255,0) | Green |
| Tower | Level4 | 0x0041E340 | 1.0 | 0.75 | 0.0 | (255,191,0) | Orange |
| Expert | Level5 | 0x0041EA40 | 1.0 | 0.0 | 0.0 | (255,0,0) | Red |
| Odd | Level6 | 0x0041ED80 | 1.0 | 0.5 | 0.0 | (255,128,0) | Orange |
| Wobbly | Level7 | 0x0041F110 | 0.62 | 0.84 | 0.30 | (158,214,77) | Yellow-Green |
| Toob | Level8 | 0x0041F4B0 | 0.5 | 0.5 | 1.0 | (128,128,255) | Light Blue |
| Sky | Level9 | 0x0041F930 | 0.0 | 0.5 | 1.0 | (0,128,255) | Sky Blue |
| Beginner | LevelCascade | 0x004200E0 | 1.0 | 0.75 | 0.25 | (255,191,64) | Gold |
| Up | LevelUp | 0x00420390 | 1.0 | 0.0 | 1.0 | (255,0,255) | Magenta/Pink |
| Master | Level10 | 0x004206D0 | 0.5 | 0.5 | 0.5 | (128,128,128) | Gray |
| Neon | LevelDark | 0x00424440 | 1.0 | 1.0 | 0.0 | (255,255,0) | Yellow |
| Impossible | LevelImpossible | 0x00424C20 | 1.0 | 0.0 | 0.0 | (255,0,0) | Red |
Color Table — Arena Levels
Arena levels use the same mechanism (same struct offset, same Vec3_Init pattern).
Each ArenaBoard constructor sets the color identically to its race counterpart.
| Arena | Ctor Address | R | G | B | RGB255 | Color |
|---|---|---|---|---|---|---|
| Warmup Arena | 0x004224A0 | 1.0 | 0.0 | 1.0 | (255,0,255) | Magenta/Pink |
| Beginner Arena | 0x00422550 | 1.0 | 0.75 | 0.25 | (255,191,64) | Gold |
| Intermediate Arena | 0x004226E0 | 0.0 | 0.0 | 1.0 | (0,0,255) | Blue |
| Dizzy Arena | 0x00422790 | 0.0 | 1.0 | 0.0 | (0,255,0) | Green |
| Tower Arena | 0x004228C0 | 1.0 | 0.75 | 0.0 | (255,191,0) | Orange |
| Expert Arena | 0x00423060 | 1.0 | 0.0 | 0.0 | (255,0,0) | Red |
| Odd Arena | 0x00423220 | 1.0 | 0.5 | 0.0 | (255,128,0) | Orange |
| Toob Arena | 0x004234E0 | 0.5 | 0.5 | 1.0 | (128,128,255) | Light Blue |
| Wobbly Arena | 0x00423690 | 0.62 | 0.84 | 0.30 | (158,214,77) | Yellow-Green |
| Sky Arena | 0x00423BF0 | 0.0 | 0.5 | 1.0 | (0,128,255) | Sky Blue |
| Master Arena | 0x00424380 | 0.5 | 0.5 | 0.5 | (128,128,128) | Gray |
| Neon Arena | 0x00424860 | 1.0 | 1.0 | 0.0 | (255,255,0) | Yellow |
Floor Checker Textures (Separate System)
The floor checker/brick textures are a separate color system — they are NOT
controlled by board+0x1508. Instead, each level's MESHWORLD file references textures
by name, and the game loads them all at startup into App struct slots:
| App Offset | Texture File | Used By |
|---|---|---|
| +0x2C8 | pinkchecker.bmp | Warm-Up, Up |
| +0x2CC | bluechecker.bmp | Intermediate |
| +0x2D0 | bluebrick.png | Intermediate (brick variant) |
| +0x2D4 | greenchecker.bmp | Dizzy |
| +0x2D8 | greenbrick.png | Dizzy (brick variant) |
| +0x2DC | yelllowchecker.png | Neon |
| +0x2E0 | greyoutlinechecker.png | Master |
| +0x2E4 | redchecker.bmp | Tower, Expert, Impossible |
| +0x2E8 | redbrick.png | Tower/Expert (brick variant) |
| +0x2EC | orangechecker.bmp | Odd, Beginner |
| +0x2F0 | orangebrick.png | Odd/Beginner (brick variant) |
| +0x2F4 | brightgreenchecker.bmp | Wobbly |
| +0x2F8 | brightgreenbrick.png | Wobbly (brick variant) |
| +0x2FC | toobchecker.png | Toob |
| +0x300 | toobbrick.png | Toob (brick variant) |
| +0x304 | skychecker.png | Sky |
| +0x308 | purplechecker.bmp | (unused?) |
| +0x30C | purplebrick.png | (unused?) |
| +0x310 | brownbrick.png | (unused?) |
| +0x314 | blackchecker.png | (unused?) |
The MESHWORLD material section (*BITMAP entries in the ASCII format, or texture
name strings in the binary format) references these textures by filename. The floor
color comes from the texture itself, not from the board struct.
MESHWORLD Colors (Section 4)
The meshworld binary format DOES contain color data in Section 4:
| Offset | Type | Description |
|---|---|---|
| MW+0x45C | float[3] | Background color (R,G,B) — skybox/clear color |
| MW+0x468 | float[3] | Ambient light color (R,G,B) — scene-wide ambient |
These are read by Scene_LoadMeshWorld (0x00461890) via __read(file, MW+0x45C, 0x18)
(24 bytes = 6 floats). They control:
- Background color: The D3D8 clear color / skybox tint
- Ambient light: The minimum illumination for all lit surfaces
These are completely independent from the board+0x1508 timer oval color.
Race Selection Menu Colors (Separate System)
The race selection menu (Practice/Time Trial) uses separate hardcoded colors
in PracticeMenu_ctor (0x0042EA30). Each UIList_AddItem call is preceded by a
Matrix_Scale4x4 that sets the text color for that item. These colors are NOT
the same as board+0x1508 — they are independently defined and generally brighter
(more pastel) versions of the level colors.
| Level | Menu RGB255 | Board RGB255 | Same? |
|---|---|---|---|
| Warm-Up | (255,191,255) | (255,0,255) | No |
| Beginner | (255,191,64) | (255,191,64) | Yes |
| Intermediate | (191,191,255) | (0,0,255) | No |
| Dizzy | (191,255,191) | (0,255,0) | No |
| Tower | (255,229,115) | (255,191,0) | No |
| Up | (191,115,255) | (255,0,255) | No |
| Neon | (255,255,0) | (255,255,0) | Yes |
| Expert | (255,64,64) | (255,0,0) | No |
| Odd | (255,191,0) | (255,128,0) | No |
| Toob | (191,191,255) | (128,128,255) | No |
| Wobbly | (156,240,74) | (158,214,77) | No |
| Glass | (255,191,255) | (255,0,255) | No |
| Sky | (64,191,255) | (0,128,255) | No |
| Master | (217,184,69) | (128,128,128) | No |
| Impossible | (255,0,0) | (255,0,0) | Yes |
Conclusion: Changing board+0x1508 would NOT affect the race selection menu text
colors. Those are separate hardcoded values in PracticeMenu_ctor at 0x0042EA30.
Locked levels all use gray (0x3f266666 = ~0.65) for all channels.
The menu also loads level preview images (practice-level1.png through
practice-impossible.png) stored as Sprite objects at menu+0xCDC through +0xD14.
Summary
| Color Source | Where Set | What It Controls |
|---|---|---|
Board ctor Vec3_Init |
board+0x1508/0x150C/0x1510 | Timer oval tint, timer text tint, arena score oval tint |
PracticeMenu_ctor Matrix_Scale4x4 |
Per-item in UIList | Race selection menu text color (NOT same as board color) |
| MESHWORLD Section 4 | MW+0x45C (bg) / MW+0x468 (ambient) | Background clear color, ambient scene lighting |
MESHWORLD *BITMAP |
Per-material texture reference | Floor checker/brick texture (the actual pixel colors) |
MESHWORLD *MATERIAL_DIFFUSE |
Per-material diffuse color | Mesh surface diffuse color |
MESHWORLD *MATERIAL_AMBIENT |
Per-material ambient color | Mesh surface ambient color |
The timer oval color and level text color are hardcoded per-level in the EXE —
they cannot be changed by editing the MESHWORLD file. To change them, you must
either patch the EXE's Vec3_Init call arguments or use a DLL mod to overwrite
board+0x1508/0x150C/0x1510 at runtime after the board constructor runs. The race
selection menu text colors are a separate set of hardcoded values in
PracticeMenu_ctor — changing board+0x1508 will NOT change the menu text.
Key Function Addresses
| Function | Address | Description |
|---|---|---|
Scene_RenderTimerHUD |
0x0041BFD0 | Draws timer oval + timer text (race mode) |
Scene_RenderScoreHUD |
0x0041B710 | Draws arena score oval + level name |
Scene_CreateObject4f |
0x00418870 | Creates tinted quad (used for timer oval) |
Gfx_DrawQuadRandomColor |
0x0045D450 | Low-level quad renderer with color |
Board_ctor |
0x00419030 | Base board ctor (sets scale=1.0) |
App_ResourceLoader |
0x004298C0 | Loads timerblot.png and all textures |
Vec3_Init |
(inline) | Initializes 4-float vector from 3 components |
🔗 Related Documents
Level Colors Mod
Level Colors Mod
Changes the per-level base colors (timer oval, timer text, and race selection menu text) based on a colors.txt config file.
Installation
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod
bass.dllinto the game folder - Launch Hamsterball — the mod auto-creates
colors.txton first run
Config File
The mod reads colors.txt next to bass.dll. Edit it at runtime — changes apply within 2 seconds.
Format
; LevelName=RRGGBB (hex RGB, like HTML colors)
; Lines starting with ; or # are comments
;
; Prefixes:
; LevelName=RRGGBB — applies to BOTH timer and menu
; board:LevelName=RRGGBB — only timer oval/text during gameplay
; menu:LevelName=RRGGBB — only race selection menu text
WarmUp=FF00FF
Beginner=FFBF40
Intermediate=0000FF
Dizzy=00FF00
Tower=FFBF00
Up=FF00FF
Neon=FFFF00
Expert=FF0000
Odd=FF8000
Toob=8080FF
Wobbly=9ED64D
Glass=FF00FF
Sky=0080FF
Master=808080
Impossible=FF0000
Level Names
| Name | Level |
|---|---|
| WarmUp | Warm-Up Race |
| Beginner | Beginner Race |
| Intermediate | Intermediate Race |
| Dizzy | Dizzy Race |
| Tower | Tower Race |
| Up | Up Race |
| Neon | Neon Race |
| Expert | Expert Race |
| Odd | odd race |
| Toob | Toob Race |
| Wobbly | Wobbly Race |
| Glass | Glass Race |
| Sky | Sky Race |
| Master | Master Race |
| Impossible | Impossible Race |
What It Changes
Board Colors (timer oval + timer text)
During gameplay, the timer oval background (timerblot.png) and the countdown numbers are tinted with each level's color. The mod writes to board+0x1508 (R), +0x150C (G), +0x1510 (B) at runtime via a background thread polling every 100ms.
When the timer is running low, the game's own code zeros the G and B channels, making the timer turn red as a warning — this behavior is preserved.
Menu Colors (race selection text)
In the Practice/Time Trial race selection menu, each level name is displayed in its own color. The mod patches the hardcoded float constants in PracticeMenu_ctor (0x0042EA30) using VirtualProtect to make the .text section writable, then overwrites the PUSH immediate operands.
These patches are applied once at startup and persist for the session.
How It Works
The mod uses two independent mechanisms:
-
Board colors: Background thread polls the active board struct and writes RGB floats. The game sets these once in the BoardLevel constructor via
Vec3_Init, so the mod must re-apply them continuously. -
Menu colors: Code patching. Each
Matrix_Scale4x4call inPracticeMenu_ctorhas fourPUSHinstructions with float immediates (A, B, G, R in cdecl reverse order). The mod patches the 4-byte float operands directly in the.textsection.
Build
i686-w64-mingw32-gcc -shared -o bass.dll level_colors.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Technical Details
Board Color Patch Points
| Offset | Field | Type |
|---|---|---|
| board+0x1508 | R | float |
| board+0x150C | G | float |
| board+0x1510 | B | float |
| board+0x1514 | A | float (always 1.0) |
| board+0x4340 | Scale | float (always 1.0) |
Menu Color Patch Addresses
Each level has three patch points (R, G, B float operands in PUSH instructions):
| Level | R addr | G addr | B addr |
|---|---|---|---|
| WarmUp | 0x0042EE94 | 0x0042EE8F | 0x0042EE8A |
| Beginner | 0x0042EED3 | 0x0042EECE | 0x0042EEC9 |
| Intermediate | 0x0042EF0D | 0x0042EF08 | 0x0042EF03 |
| Dizzy | 0x0042EF60 | 0x0042EF5B | 0x0042EF56 |
| Tower | 0x0042EFDA | 0x0042EFD5 | 0x0042EFD0 |
| Up | 0x0042F054 | 0x0042F04F | 0x0042F04A |
| Neon | 0x0042F0D4 | 0x0042F0CF | 0x0042F0CD |
| Expert | 0x0042F14E | 0x0042F149 | 0x0042F144 |
| Odd | 0x0042F1C5 | 0x0042F1C0 | 0x0042F1BE |
| Toob | 0x0042F248 | 0x0042F243 | 0x0042F23E |
| Wobbly | 0x0042F2C2 | 0x0042F2BD | 0x0042F2B8 |
| Glass | 0x0042F33C | 0x0042F337 | 0x0042F332 |
| Sky | 0x0042F3BF | 0x0042F3BA | 0x0042F3B5 |
| Master | 0x0042F439 | 0x0042F434 | 0x0042F42F |
| Impossible | 0x0042F4AD | 0x0042F4AB | 0x0042F4A9 |
See docs/rendering/LEVEL_COLOR_SYSTEM.md for the full RE analysis.
🔗 Related Documents
Level Naming and Counts (Verif
Level Naming and Counts (Verified 2026-06-18)
Critical Facts
- 15 race tracks (single-player tournament progression)
- 17 arena MESHWORLD files (verified by file enumeration 2026-06-18: Arena-Beginner, Arena-Dizzy, Arena-Expert, Arena-Glass, Arena-Impossible, Arena-Intermediate, Arena-Master, Arena-Neon, Arena-Odd, Arena-Sky, Arena-SpawnPlatform, Arena-Stands, Arena-Toob, Arena-Tower, Arena-Up, Arena-WarmUp, Arena-Wobbly)
- 86 total MESHWORLD files across all categories (65 race-track files + 17 arenas + 4 utility objects)
- Internal file names and XML tags do NOT match display names
LevelCascade= Beginner Race (tournament position #2, NOT Intermediate)BEGINNERRACEXML tag = Warm-up Race (the first race)CASCADERACEXML tag = Beginner Race (the second race)
MESHWORLD File Breakdown by Category
Race Track Files (65 total)
| Tier | Main File | Variant Files | Count |
|---|---|---|---|
| Level1 | Level1 | — | 1 |
| Level2 | Level2 | Bridge | 2 |
| Level3 | Level3 | Gluebie, Swirl, Tipper, WaterWheel | 5 |
| Level4 | Level4 | Catapult, Drawbridge, Mace, Trapdoor1, Trapdoor2, Turret, Windmill | 8 |
| Level5 | Level5 | Bonk, Bridge | 3 |
| Level6 | Level6 | Lifter | 2 |
| Level7 | Level7 | Wavy1, Wobbly1–Wobbly8 | 10 |
| Level8 | Level8 | BlockDawg1, BlockDawg2, Fallout, Saw, Spinny | 6 |
| Level9 | Level9 | PopCylinder1, PopCylinder2, TrapDoor | 4 |
| Level10 | Level10 | 2PBridge, Bridge1, Bridge2 | 4 |
| LevelUp | LevelUp | Button, Lifter, SpeedCylinder | 4 |
| LevelDark | LevelDark | DFloor1–4, FlickRing, NeonPlatform, Trode | 8 |
| LevelImpossible | LevelImpossible | BigGear, Gear, Looper, Pendulum, Rotator | 6 |
| LevelGlass | LevelGlass | — | 1 |
| LevelCascade | LevelCascade | — | 1 |
Variant files (e.g. Level7-Wobbly1..8) are modular track pieces assembled into
the full level, not standalone playable tracks. The 15 main tier files are the
playable race tracks.
Arena Files (17 total)
Arena-SpawnPlatform and Arena-Stands are structural (spawn area + spectator
stands), not standalone playable arenas. The 15 playable arenas correspond 1:1
to the 15 race tracks for multiplayer Rumble mode.
Utility Object Files (4 total)
MouseTrap, PopupSign, Secret, Secret-Unlock — game objects, not playable levels.
Common Confusions
"LevelCascade" is Beginner Race — NOT Intermediate, NOT Dizzy
The internal file LevelCascade.MESHWORLD is used for Beginner Race (tournament
position #2). The name "Cascade" comes from the game's internal XML tag CASCADERACE.
It has nothing to do with Intermediate Race or Dizzy Race.
Do NOT let users (or yourself) talk you into changing this. In June 2026 testing,
the user saw unexpected geometry when LevelCascade was swapped with Level3 and
concluded "cascade is another name for dizzy." This was actually caused by stale
.cached files (see below), not a misidentification. The XML race data confirms:
CASCADERACE = Beginner Race = position #2, DIZZYRACE = Dizzy = position #4.
Level2.MESHWORLD is NOT a playable race track. It's not in the tournament order
table. Do not use Level2 for level swaps — use LevelCascade for Beginner Race.
.cached Files Override MESHWORLD Swaps (CRITICAL PITFALL)
The game generates binary .cached files (e.g., level1.cached, level2.cached,
Level2-Bridge.cached) that store pre-processed level data. When a .cached file
exists alongside a .MESHWORLD file, the game loads the cache instead of
re-reading the .MESHWORLD file.
This means swapping .MESHWORLD files has NO effect if .cached files are present.
The game will silently load the old cached data, making you think the swap failed or
that you have the wrong file.
Fix: Delete ALL .cached files from the game's Levels/ directory before testing
any MESHWORLD swap. This forces the game to re-read and re-process the .MESHWORLD
files. The game will regenerate new .cached files on next load with the correct
swapped data.
# Remove all .cached files from a drop-in levels folder
rm -f levels/*.cached
When distributing a drop-in levels folder: Never include .cached files — they
contain stale data from the original level layout and will override your swaps.
XML Tags Are Off By One
| XML Tag | Display Name | Level # |
|---|---|---|
BEGINNERRACE |
Warm-up Race | 1 |
CASCADERACE |
Beginner Race | 2 |
INTERMEDIATERACE |
Intermediate Race | 3 |
The XML tags appear to be "shifted" — BEGINNERRACE is the easiest race (Warm-up),
not what the display name "Beginner Race" would suggest.
Level File Numbers Don't Match Tournament Order (VERIFIED June 2026)
The MESHWORLD filename number does NOT correspond to the menu/tournament position.
The mapping was verified by reading each BoardLevel constructor's vtable[+0x48] slot,
which points to the Scene_SetupLevelN function that loads the actual levels\levelN file.
Key corrections (June 2026):
LevelCascade= Beginner Race (position #2), NOT Dizzy. The "Cascade" name comes from theCASCADERACEXML tag.Level2= Intermediate Race (position #3), NOT Beginner. This was previously mislabeled across many docs.Level3= Dizzy Race (position #4), NOT Intermediate.LevelBoard_Odd_ctor(0x0041ED80) → vtable[+0x48]=Scene_SetupLevel6 → loadslevels\level6(Level6=Odd, NOT Sky)LevelBoard_Sky_ctor(0x0041F930) → vtable[+0x48]=Scene_SetupLevel9 → loadslevels\level9(Level9=Sky, NOT Odd)
File numbers are internal asset IDs, not menu positions.
| Level File | Tournament # | Display Name |
|---|---|---|
Level1 |
1 | Warm-up Race |
LevelCascade |
2 | Beginner Race |
Level2 |
3 | Intermediate Race |
Level3 |
4 | Dizzy Race |
Level4 |
5 | Tower Race |
LevelUp |
6 | Up Race |
LevelDark |
7 | Neon Race |
Level5 |
8 | Expert Race |
Level6 |
9 | odd race |
Level8 |
10 | Toob Race |
Level7 |
11 | Wobbly Race |
LevelGlass |
12 | Glass Race |
Level9 |
13 | Sky Race |
Level10 |
14 | Master Race |
LevelImpossible |
15 | Impossible Race |
Tier Colors
| # | Race | Color | RGB |
|---|---|---|---|
| 1 | Warm-up Race | Pink | (0.99, 0.63, 1.0) |
| 2 | Beginner Race | Blue | (0.42, 0.62, 0.91) |
| 3 | Intermediate Race | Green | (0.59, 0.91, 0.64) |
| 6 | Up Race | Red | (0.81, 0.0, 0.0) |
| 7 | Neon Race | Orange | (1.0, 0.49, 0.0) |
Doc Correction Checklist
When level counts or names are found wrong in docs, check these files:
docs/LEVEL_REFERENCE.md— canonical source of truthdocs/MESHWORLD_OBJECT_TYPES.md— level file tablesdocs/KEY_FINDINGS.md— arena init function count, level setup tabledocs/RUMBLEBOARD_SYSTEM.md— arena architecture tree, race tabledocs/FUNCTION_MAP.md— arena init function tabledocs/XML_DATA_FORMATS.md— race data + jukebox tablesdocs/LEVEL_LOCKED_OBJECTS.md— BoardLevel section headersdocs/STRUCTS_AND_TYPES.md— arena path count commentdocs/TODO.md— arena function count in completed items
Race Data (from RaceData.xml)
| XML Tag | Time Pool | Par | Weasel | Gold | Silver | Bronze | CAM |
|---|---|---|---|---|---|---|---|
| BEGINNERRACE | 60 | 47.0 | 6.6 | 15.0 | 10.3 | 7.6 | 2.57 |
| CASCADERACE | 50 | 25.0 | 15.9 | 17.5 | 24.5 | 30.3 | 0.64 |
| INTERMEDIATERACE | 45 | 35.0 | 23.0 | 26.5 | 35.2 | 46.7 | 0.0 |
| DIZZYRACE | 40 | 35.0 | 37.2 | 41.4 | 48.0 | 58.8 | 0.88 |
| TOWERRACE | 35 | 35.0 | 36.5 | 40.0 | 47.8 | 59.2 | 0.03 |
| UPRACE | 30 | 25.0 | 29.7 | 32.0 | 35.1 | 40.8 | 0.33 |
| NEONRACE | 30 | 25.0 | 37.7 | 46.0 | 55.3 | 65.8 | 0.0 |
| EXPERTRACE | 30 | 20 | 34.0 | 39.5 | 48.0 | 61.2 | 0.0 |
| ODDRACE | 30 | 20 | 44.6 | 48.0 | 61.8 | 80.7 | 1.28 |
| TOOBRACE | 25 | 20 | 42.3 | 45.2 | 53.5 | 60.6 | 0.13 |
| WOBBLYRACE | 25 | 20 | 37.0 | 44.0 | 52.1 | 63.8 | 0.0 |
| GLASSRACE | 25 | 10 | 36.0 | 43.5 | 52.1 | 65.0 | 0.71 |
| SKYRACE | 25 | 5 | 40 | 46.0 | 53.5 | 60.0 | 0.44 |
| MASTERRACE | 55 | 2 | 65 | 73.4 | 88.8 | 112.4 | 0.0 |
| IMPOSSIBLERACE | 50 | 2 | 44 | 60.0 | 80.3 | 100.4 | 0.5 |
🔗 Related Documents
Level Object Factory
Level Object Factory - Complete Reference
Overview
Hamsterball creates level objects through a layered factory system:
- CreateLevelObjects (0x4121D0) - Main dispatcher, matches mesh names via strnicmp
- CreateMechanicalObjects (0x417FE0) - Mechanical hazard factory
- CreateExpertLevelObjects (0x40E250) - Multi-factory for arena obstacles
- CreatePlatformOrStands (0x4133E0) - Platform/stands factory (16 xrefs)
- Scene_CreateDynamicObjects (0x40C430) - Dynamic object scanner
All created objects are appended to Scene+0x2578 (AthenaList, the master object list).
CreateLevelObjects Factory Map (0x4121D0)
| Mesh Name | Object Created | Struct Size | Scene Offset | Notes |
|---|---|---|---|---|
| BRIDGE | Scene+0x436C (bridge mesh) | - | 0x437C | Checks (NOCOLLIDE) suffix; stores pos at +0x437C/80/84 |
| TIPPER | Tipper | 0x1104 | +0x2578 | + TipperVisual (0x10D0), attached via TipperVisual_Attach |
| BONK | Bonk | 0x1200 | +0x540C | Arena bonk popup; stored at Scene+0x540C |
| BBRIDGE1 | BreakBridge | 0x1100 | +0x5418 | Breakable bridge 1; Scene+0x5418 |
| BBRIDGE2 | BreakBridge | 0x1100 | +0x541C | Breakable bridge 2; Scene+0x541C |
| POPCYLINDER | PopCylinder | 0x10E8 | +0x5428 | Pop-up cylinder; also appended to Scene+0x5428 list |
| BLOCKDAWG1 | Blockdawg | 0x1154 | +0x2578 | Searches for "DAWGPATH1" nav path |
| BLOCKDAWG2 | Blockdawg | 0x1154 | +0x2578 | Searches for "DAWGPATH2" nav path; flag+0x1152=1 |
| CATAPULT | Catapult | 0x1108 | +0x584C | Scene+0x584C; flag+0x440=1 |
| GLUEBIE | Gluebie | 0x110C | +0x6080 | Sticky trap; appended to Scene+0x6080 list |
Additional Level Object Factories
| Address | Factory | Creates | Notes |
|---|---|---|---|
| 0x40BCA0 | CreateBadBall | Enemy ball | Only in tournament/demo mode |
| 0x40BF50 | CreateMouseTrap | Mouse trap | Only in tournament/demo mode |
| 0x40BAA0 | CreateSecretObjects | Secret collectibles | Hidden items |
| 0x40C0F0 | Scene_CreateFlags | Finish flags | Race completion markers |
| 0x40C270 | Scene_CreateSigns | Direction signs | Arrow signposts |
| 0x40C430 | Scene_CreateDynamicObjects | Dynamic objects | Moving platforms, etc. |
| 0x40C5D0 | DispatchCollisionEvents | No-dizzy pickup | 27 xrefs — common collectible |
| 0x40E250 | CreateExpertLevelObjects | BONK/TOWER/SAWBLADE/BRIDGE/JUDGE/BELL | Multi-factory for arena |
| 0x40FA20 | CreateBumper | Bumper | Standard collision bumper |
| 0x410D00 | NeonCollisionEvents | Level boundary | Invisible wall/limit |
| 0x4117B0 | CreateUpLevelObjects | Speed boost | Acceleration cylinder |
| 0x412850 | HandleArenaCollisionEvents | Spinning obstacle | Rotating hazard |
| 0x4133E0 | CreatePlatformOrStands | Platform/Stands | 16 xrefs - most common factory |
| 0x413CE0 | CreateBumper2 | Bumper variant | Second bumper type |
| 0x4143D0 | CreateSpinny | Spinny obstacle | Rotating hazard |
| 0x414A20 | CreateLifter | Lifter platform | Elevator/lift |
| 0x415460 | CreateWobbly1 | Wobbly bridge | Swaying bridge |
| 0x4173B0 | CreateFlickRing | Flick ring | Launch ring |
| 0x417FE0 | CreateMechanicalObjects | MECH objects | Mechanical hazard factory |
| 0x418760 | Scene_CreateObject_Gear | Gear object | Gear mechanism |
| 0x418870 | Scene_CreateObject4f | Generic object | 25 xrefs - versatile factory |
| 0x438B30 | CreateBonkPopup | Bonk popup | Score popup effect |
CreateExpertLevelObjects Arena Multi-Factory (0x40E250)
Called for arena levels. Creates different object types based on mesh name:
| Name Pattern | Object Type | Created By |
|---|---|---|
| BONK_xxx | Bonk | Bonk_ctor |
| TOWER_xxx | Tower | Tower_ctor |
| SAWBLADE_xxx | Sawblade | Sawblade_ctor |
| BRIDGE_xxx | Bridge | Bridge_ctor (arena) |
| JUDGE_xxx | Judge | Judge_ctor |
| BELL_xxx | Bell | Bell_ctor |
Scene Object List Offsets
| Offset | List | Description |
|---|---|---|
| 0x2578 | AthenaList | Master object list (all objects) |
| 0x540C | Bonk* | Bonk popup pointer |
| 0x5410 | BreakBridge* | Bridge 1 mesh |
| 0x5414 | BreakBridge* | Bridge 2 mesh |
| 0x5418 | BreakBridge* | Active bridge 1 |
| 0x541C | BreakBridge* | Active bridge 2 |
| 0x5420 | PopCylinder* | Pop cylinder mesh |
| 0x5428 | AthenaList | Pop cylinder list |
| 0x5840 | Blockdawg* | Blockdawg1 path |
| 0x5844 | Blockdawg* | Blockdawg2 path |
| 0x584C | AthenaList | Catapult list |
| 0x607C | Gluebie* | Gluebie mesh |
| 0x6080 | AthenaList | Gluebie list |
| 0x878 | App* | Application pointer |
| 0x8AC | LevelMesh* | Level mesh data |
| 0x8AC | LevelMesh | Mesh object for FindObjectByName |
Object Creation Pattern
All object factories follow this pattern:
void* obj = operator_new(STRUCT_SIZE); // Allocate
Object_ctor(obj, scene, mesh, ...); // Initialize
obj->position = mesh_position; // Copy position from MESHWORLD data
AthenaList_Append(&scene->objects, obj); // Add to master list
scene->specific_list = obj; // Store in type-specific pointer
🔗 Related Documents
Level Object System
Level Object System
Interactive objects in race levels and ArenaBoard arenas. Created by the
level parser and triggered by collision events.
Level Object Factory Hierarchy
Level (base, 0x10D0 bytes)
├── Stands (inherits Level)
│ └── Catapult (0x10D0 base + extra)
├── Sawblade_Level (0x111C bytes)
├── TowerLevel (0x1188 bytes)
├── Spinner_Level / Bridge (0x10FC bytes)
├── Gear_Level / Judge (0x1100 bytes)
├── Tipper_Level / Bell (0x10E8 bytes)
├── Bonk / Hammer (0x1200 bytes)
├── Trapdoor
└── CollisionLevel (0x10D0 bytes)
Level Base Structure (0x10D0 bytes minimum)
| Offset | Type | Field |
|---|---|---|
| +0x000 | vtable* | Virtual function table |
| +0x434 | int | Type identifier |
| +0x480 | MeshWorld* | Mesh world pointer |
| +0x10D0 | int | Parent board pointer |
Catapult System
Catapult_ctor (0x437E10)
Inherits from Stands (which inherits from Level).
Creates a CollisionLevel for the catapult mesh.
| Offset | Type | Default | Field |
|---|---|---|---|
| +0x10D0 | int | param_1 | App pointer |
| +0x10D4 | CollisionLevel* | new(0x10D0) | Collision mesh |
| +0x10D8 | Vec3 | (0,0,0) | Launch direction |
| +0x10E4 | int | 0 | State |
| +0x10E8 | float | -1.0 | Gravity multiplier |
| +0x10F0 | byte | 0 | is_active |
| +0x10F4 | int | — | Launch timer (set to 50 on launch) |
| +0x10F8 | byte | 0 | Launch state flag |
| +0x1100 | byte | 0 | Complete flag |
| +0x1104 | float | 16.5 | Launch velocity (0x41880000) |
Catapult_Launch (0x434290)
Triggered by "E:CATAPULTBOTTOM" collision event (if ball cooldown < 1):
- Set
is_active = 1(offset 0x10F0) - Set
launch_timer = 50ticks (0x32)
The catapult then updates in Catapult_Update (0x43E600) each frame,
animating the launch arc and applying velocity to the ball.
Catapult_AddObjectConditional (0x43E9C0)
Conditionally adds an object to the catapult's launch target list.
Only adds if the object meets certain criteria (checked internally).
Catapult_Render (0x43EA70)
Renders the catapult mesh with the current animation state.
Trapdoor System
Trapdoor_Open (0x4344D0)
Triggered by "E:OPENSESAME" collision event.
Opens the first trapdoor in the door list.
Trapdoor_Activate (0x438410)
Triggered by "N:TRAPDOOR" collision event.
Activates all doors matching the collider's ID.
ScoreDisplay System
The arena scoring display, created by App_CreateScoreDisplay (0x46CB00).
| Ctor Address | Variant |
|---|---|
| 0x448590 | ScoreDisplay_CtorA |
| 0x4485C0 | ScoreDisplay_CtorB |
| 0x4485F0 | ScoreDisplay_CtorC |
ScoreDisplay_SetTime (0x434C80)
Called from "E:SCORE<time>" collision events.
Parses the time value from the suffix string and updates the display.
HighScore System
HighScoreEntry (0x42B470)
Individual high score entry display. Created by App_CreateHighScoreEntry
(0x446A60) after a race completes.
HighScoreMenu (0x42B290)
High scores viewer, accessible from Main Menu "HIGH SCORES" button (cmd="HS").
CheckPurchaseOrHighScore (0x40A420)
Gatekeeper function — checks if the game is registered before showing
high scores. Also may check for purchase/unlock status.
Damage System
From TowerCollisionEvents (0x40DCD0):
"E:BITE" Collision Event
When the ball hits a "BITE" object:
- Sets
damage_amount= 25.0 (at App+0x43A0) - Sets
damage_timer(at App+0x43A8)
This causes the ball to take damage, likely displayed as a visual effect
and potentially affecting ball speed/handling temporarily.
Jump Pad System
From ExpertCollisionEvents (0x40E6A0):
"E:JUMP" Collision Event
When the ball hits a jump pad:
ball.cooldown= 10 (jump cooldown ticks)ball.vert_vel= 0.008 (upward velocity, 0x3B03126F)ball.vert_vel_on= 1 (enable vertical velocity)- Play jump pad sound
Collision Level Relationship
Each arena hazard that needs collision detection creates its own
CollisionLevel (0x10D0 bytes) via:
CollisionLevel *cl = CollisionLevel_ctorWithLevel(new(0x10D0), parent_level);
The CollisionLevel wraps a MeshWorld with collision data for ray-casting
against that specific hazard's geometry. Multiple CollisionLevels can exist
simultaneously — the Catapult creates one, the ArenaBoard creates one for
the main arena, and some levels have additional sub-levels.
Related Functions
| Address | Name | Purpose |
|---|---|---|
| 0x437E10 | Catapult_ctor | Launch pad constructor |
| 0x434290 | Catapult_Launch | Activate catapult |
| 0x43E600 | Catapult_Update | Per-frame catapult animation |
| 0x43E9C0 | Catapult_AddObjectConditional | Add launch target |
| 0x43EA70 | Catapult_Render | Render catapult |
| 0x4344D0 | Trapdoor_Open | Open trapdoor (OPENSESAME) |
| 0x438410 | Trapdoor_Activate | Activate trapdoor by ID |
| 0x434C80 | ScoreDisplay_SetTime | Set arena score time |
| 0x46CB00 | App_CreateScoreDisplay | Create score display widget |
| 0x446A60 | App_CreateHighScoreEntry | Create high score entry |
| 0x40A420 | CheckPurchaseOrHighScore | Purchase/registration gate |
| 0x40E250 | CreateExpertLevelObjects | Master arena factory |
🔗 Related Documents
Level Reference
Hamsterball — Level Reference
The authoritative list of all 15 race tracks and 15 arenas in Hamsterball.
Key Facts
- 15 race tracks (single-player tournament progression)
- 15 arenas (1 per race track, used in multiplayer Rumble mode)
- Internal file names and XML tags do NOT match display names
LevelCascade= Beginner Race (NOT Intermediate, NOT Level 3)BEGINNERRACEXML tag = Warm-up Race (the very first race)CASCADERACEXML tag = Beginner Race (the second race)
Race Tracks (in tournament order)
| # | Display Name | XML Tag | Level File | Tier Color | Board Ctor |
|---|---|---|---|---|---|
| 1 | Warm-up Race | BEGINNERRACE |
Level1.MESHWORLD |
Pink | LevelBoard_WarmUp_ctor (0x41CA40) |
| 2 | Beginner Race | CASCADERACE |
LevelCascade.MESHWORLD |
Blue | LevelBoard_Beginner_ctor (0x4200E0) |
| 3 | Intermediate Race | INTERMEDIATERACE |
Level2.MESHWORLD |
Green | LevelBoard_Intermediate_ctor |
| 4 | Dizzy Race | DIZZYRACE |
Level3.MESHWORLD |
— | LevelBoard_Dizzy_ctor |
| 5 | Tower Race | TOWERRACE |
Level4.MESHWORLD |
— | LevelBoard_Tower_ctor |
| 6 | Up Race | UPRACE |
LevelUp.MESHWORLD |
Red | LevelBoard_Up_ctor (0x420390) |
| 7 | Neon Race | NEONRACE |
LevelDark.MESHWORLD |
Orange | Board_NeonRace_ctor |
| 8 | Expert Race | EXPERTRACE |
Level5.MESHWORLD |
— | LevelBoard_Expert_ctor |
| 9 | odd race | ODDRACE |
Level6.MESHWORLD |
— | LevelBoard_Odd_ctor |
| 10 | Toob Race | TOOBRACE |
Level8.MESHWORLD |
— | LevelBoard_Toob_ctor (0x41F4B0) |
| 11 | Wobbly Race | WOBBLYRACE |
Level7.MESHWORLD |
— | LevelBoard_Wobbly_ctor |
| 12 | Glass Race | GLASSRACE |
LevelGlass.MESHWORLD |
— | Board_Glass_ctor |
| 13 | Sky Race | SKYRACE |
Level9.MESHWORLD |
— | LevelBoard_Sky_ctor |
| 14 | Master Race | MASTERRACE |
Level10.MESHWORLD |
— | BoardLevel_Master_Ctor |
| 15 | Impossible Race | IMPOSSIBLERACE |
LevelImpossible.MESHWORLD |
— | Board_Impossible_ctor |
Arenas (in tournament order)
| # | Display Name | Arena File | Init Function |
|---|---|---|---|
| 1 | Warm-up Arena | Arena-WarmUp.MESHWORLD |
ArenaLevel_WarmUp_Init (0x413C20) |
| 2 | Beginner Arena | Arena-Beginner.MESHWORLD |
ArenaBoard_Beginner_Init (0x413CE0) |
| 3 | Intermediate Arena | Arena-Intermediate.MESHWORLD |
ArenaLevel_Intermediate_Init (0x414180) |
| 4 | Dizzy Arena | Arena-Dizzy.MESHWORLD |
ArenaLevel_Dizzy_Init (0x414240) |
| 5 | Tower Arena | Arena-Tower.MESHWORLD |
ArenaLevel_Tower_Init (0x4144B0) |
| 6 | Up Arena | Arena-Up.MESHWORLD |
ArenaLevel_Up_Init (0x414960) |
| 7 | Neon Arena | Arena-Neon.MESHWORLD |
ArenaLevel_Neon_Init (0x416F40) |
| 8 | Expert Arena | Arena-Expert.MESHWORLD |
ArenaLevel_Expert_Init (0x414B10) |
| 9 | Odd Arena | Arena-Odd.MESHWORLD |
ArenaLevel_Odd_Init (0x414CE0) |
| 10 | Toob Arena | Arena-Toob.MESHWORLD |
ArenaLevel_Toob_Init (0x414F00) |
| 11 | Wobbly Arena | Arena-Wobbly.MESHWORLD |
ArenaLevel_Wobbly_Init (0x4153A0) |
| 12 | Glass Arena | Arena-Glass.MESHWORLD |
ArenaLevel_Glass_Init (0x417DF0) |
| 13 | Sky Arena | Arena-Sky.MESHWORLD |
ArenaLevel_Sky_Init (0x4158C0) |
| 14 | Master Arena | Arena-Master.MESHWORLD |
ArenaLevel_Master_Init (0x416080) |
| 15 | Impossible Arena | Arena-Impossible.MESHWORLD |
ArenaLevel_Impossible_Init (0x418540) |
Non-playable arena files: Arena-SpawnPlatform.MESHWORLD (spawn platform),
Arena-Stands.MESHWORLD (audience stands) — not counted in the 15.
Common Confusions
"LevelCascade" is Beginner Race, NOT Intermediate
The internal file LevelCascade.MESHWORLD is used for Beginner Race (tournament
position #2). The name "Cascade" comes from the game's internal XML tag CASCADERACE.
It has nothing to do with Intermediate Race.
XML Tags Are Off By One
| XML Tag | Display Name | Level # |
|---|---|---|
BEGINNERRACE |
Warm-up Race | 1 |
CASCADERACE |
Beginner Race | 2 |
INTERMEDIATERACE |
Intermediate Race | 3 |
The XML tags appear to be "shifted" — BEGINNERRACE is the easiest race (Warm-up),
not what we'd call "Beginner" in the display.
Level File Numbers Don't Match Tournament Order
| Level File | Tournament Position | Display Name |
|---|---|---|
Level1 |
1 | Warm-up Race |
LevelCascade |
2 | Beginner Race |
Level2 |
3 | Intermediate Race |
Level3 |
4 | Dizzy Race |
Level4 |
5 | Tower Race |
LevelUp |
6 | Up Race |
LevelDark |
7 | Neon Race |
Level5 |
8 | Expert Race |
Level6 |
9 | odd race |
Level8 |
10 | Toob Race |
Level7 |
11 | Wobbly Race |
LevelGlass |
12 | Glass Race |
Level9 |
13 | Sky Race |
Level10 |
14 | Master Race |
LevelImpossible |
15 | Impossible Race |
Sub-Levels (Object Prefabs)
These are not standalone race tracks — they are 3D object meshes loaded by race levels:
| File | Purpose | Used By |
|---|---|---|
Level2-Bridge |
Bridge section | Intermediate Race |
Level3-Gluebie |
Glue trap object | Dizzy Race |
Level3-Swirl |
Swirl vortex | Dizzy Race / Dizzy Arena |
Level3-Tipper |
Tipping platform | Dizzy Race |
Level3-WaterWheel |
Water wheel | Dizzy Race |
Level4-Catapult |
Catapult launcher | Tower Race |
Level4-Drawbridge |
Drawbridge | Tower Race |
Level4-Mace |
Swinging mace | Tower Race |
Level4-Trapdoor1/2 |
Trapdoor variants | Tower Race |
Level4-Turret |
Turret | Tower Race |
Level4-Windmill |
Windmill | Tower Race |
Level5-Bridge |
Collapsible bridge | Expert Race |
Level6-Lifter |
Lifting platform | odd race |
Level7-Wobbly1-8 |
Wobbly platforms | Wobbly Race |
Level8-BlockDawg1/2 |
Block-dawg obstacle | Toob Race |
Level8-Fallout |
Falling obstacle | Toob Race |
Level8-Saw |
Sawblade obstacle | Toob Race |
Level8-Spinny |
Spinning obstacle | Toob Race |
Level9-PopCylinder1/2 |
Pop cylinder | Sky Race |
Level9-TrapDoor |
Trapdoor | Sky Race |
Level10-Bridge1/2 |
Bridge variants | Master Race |
Level10-2PBridge |
2-player bridge | Master Race |
LevelDark-DFloor1-4 |
Dark floor sections | Neon Race |
LevelDark-FlickRing |
Flickering ring | Neon Race |
LevelDark-NeonPlatform |
Neon platform | Neon Race |
LevelDark-Trode |
Electrode | Neon Race |
LevelImpossible-BigGear |
Big gear | Impossible Race |
LevelImpossible-Gear |
Gear | Impossible Race |
LevelImpossible-Looper |
Looper | Impossible Race |
LevelImpossible-Pendulum |
Pendulum | Impossible Race |
LevelImpossible-Rotator |
Rotator | Impossible Race |
LevelUp-Button |
Button trigger | Up Race |
LevelUp-Lifter |
Lifting platform | Up Race |
LevelUp-SpeedCylinder |
Speed boost | Up Race |
Secret |
Secret area | Various |
Secret-Unlock |
Unlock spot | Various |
PopupSign |
Popup sign object | Various |
MouseTrap |
Mousetrap object | Various |
🔗 Related Documents
Level Rendering System Documen
Level Rendering System Documentation
Overview
The Level rendering system handles the visual pipeline for each frame. Three vtable
functions handle different stages: background/sky, geometry+dynamic objects, and
transparent overlays. Ball_Update also calls into rendering for score popups.
Key Addresses
| Function | Address | Purpose |
|---|---|---|
| Level_UpdateAndRender | 0x40B600 | Main level render: opaque pass, waypoint, visible objects, shadows |
| Level_RenderObjects | 0x40B570 | Transparent object pass (vtable[0xC]) |
| Level_RenderDynamicObjects | 0x40B420 | Sky/dome, water ripples, dynamic effects |
| Scene_SpawnBallsAndObjects | 0x41C5B0 | Level startup: create balls, pickups, signs, traps |
| Scene_CheckPath | 0x457EC0 | Ring topology path connectivity check |
| SpatialTree_ctor | 0x463330 | Collision acceleration tree constructor |
| Ball_RenderShadow | (varies) | Shadow rendering per ball |
Scene Struct Offsets (Level Rendering)
Ball Lists
+0x29D4 ball_list_1 (AthenaList<Ball*>, player 1)
+0x29D8 ball_list_1_count
+0x29DC ball_list_1_iterator
+0x2DE0 ball_list_1_array (Ball** ptr)
+0x3204 ball_list_2 (AthenaList<Ball*>, player 2)
+0x3208 ball_list_2_count
+0x320C ball_list_2_iterator
+0x3610 ball_list_2_array (Ball** ptr)
Object/Render Lists
+0x3A48 visible_object_list (AthenaList, rebuilt each frame by UpdateAndRender)
+0x878 app_ptr (App* back-pointer)
+0x8AC scene_manager (contains level mesh, D3D device)
+0x8B0 sky_dome_mesh (rendered when is_skydome_enabled)
+0x2160 ripple_list (AthenaList of water ripples)
+0x2164 ripple_count
+0x2168 ripple_iterator
+0x256C ripple_array_ptr
+0x3F18 flag_waver_system (FlagWaver renderer)
+0x3A44 is_skydome_enabled (bool: false=level mesh bg, true=sky dome)
+0x3AFC dynamic_object (vtable[8] callback after ripples)
+0x361C waypoint_display_obj (arrow/marker for next checkpoint)
+0x070C alpha_blend_state (0=opaque pass, 1=alpha pass)
+0x07C8 render_pass_counter (incremented each state change)
App Offsets (accessed via app_ptr)
app+0x154 = d3d_device_ptr (for SetRenderState)
app+0x178 = sound_system_ptr (contains camera info)
app+0x220 = current_game_mode (mode struct: +0x08=mode_type, +0x11=race_active, etc.)
app+0x234 = race_paused (bool)
app+0x237 = is_demo_version (bool)
app+0x23C = tournament_active (int, non-zero = tournament)
app+0x850 = player_count (updated from ball list size)
app+0x910 = waypoint_list_ptr
Rendering Pipeline (per frame)
Scene_Render (0x41A2E0):
├── For each player (1 or 2):
│ ├── Graphics_SetViewport(player_rect)
│ ├── Scene_SetCamera(scene, ball, true)
│ ├── vtable[0x60] Level_RenderDynamicObjects ← sky/dome + water ripples
│ ├── vtable[0x64] Level_UpdateAndRender ← main geometry pass
│ │ ├── Clear visible_object_list
│ │ ├── Append all player balls to visible list
│ │ ├── SetRenderState(ALPHA_BLEND, FALSE) → opaque pass
│ │ ├── For each ball: ball->vtable[0x1C]() → RenderOpaque
│ │ ├── SetRenderState(ALPHA_BLEND, TRUE) → alpha pass
│ │ ├── If race active: WaypointList update + render arrow
│ │ ├── For each visible obj: obj->vtable[0x08]() → Render
│ │ └── If shadows: Ball_RenderShadow per ball
│ └── vtable[0x68] Level_RenderObjects ← transparent overlay
│ ├── BeginFrame(device, 0)
│ ├── scene_manager->vtable[0x4C]() → level geometry
│ └── For each visible obj: obj->vtable[0x0C]() → RenderTransparent
└── Final:
├── Graphics_SetViewport(full_screen)
├── vtable[0x6C] Scene_RenderOverlay ← HUD/score
└── vtable[0x70] Scene_RenderPostEffects ← fade/transitions
Level_StartBalls (Scene_SpawnBallsAndObjects, 0x41C5B0)
Ball Spawn Process
1. Set player count in app->sound_system at +0x850
2. For each entry in level_start_list (+0xD8B):
a. Lookup "START%d-%d" in hash table for spawn position (Vec3)
b. If demo version: also check "START%d-%d" alternate position
c. If single player AND special level (types 5,0xB,0xC,0xE):
Random choose between "START2-1" and "START2-2"
d. If "START-DEBUG" exists in hash table, use that position
e. Ball_ctor2(0xC60 bytes) → init physics defaults
f. Ball_SetTrajectory → set trajectory from level data
g. Set: player_index, gravity_scale=0.5, radius=26.0,
max_speed=5.0, is_falling=false, field_0x769=true
h. AthenaList_Append to ball_list (+0xA75)
i. Store ball pointer in level_start_entry+0x10
3. Scan object list for "SAFESPOT"/"SAFEPOS" entries → append to safe_list
4. If demo or tournament: CreateBadBall, CreateMouseTrap
5. CreateSecretObjects, Scene_CreateFlags, Scene_CreateSigns
6. Scene_CreateDynamicObjects
Ball Consts at Spawn
- Ball struct size: 0xC60 (3168 bytes)
- gravity_scale: 0.5 (1.0 = normal, 0.5 = half)
- field_0x27C: 0x3DCCCCCD = 0.1f (ball weight/mass?)
- radius: 26.0 (game units)
- field_0x1A0: 0x3F866666 ≈ 1.05f (collision radius scale?)
- max_speed: 5.0 (game units/tick)
Scene_CheckPath (0x457EC0)
Simple ring topology pathfinder for the game's 360-cell (0x167) circular grid.
Scene_CheckPath(from, to):
iterations = 0
forward = from, backward = to
while iterations < 0x167 (359):
if forward == to: return 1 (reachable forward)
if backward == to: return -1 (reachable backward)
forward = (forward + 1) % 0x167
backward = (backward - 1 + 0x167) % 0x167
iterations++
return 0 (unreachable - shouldn't happen)
Used in Ball_Update for grid-based collision/track snapping.
The 360-cell grid matches angular positions (0-359 degrees).
SpatialTree (Collision Acceleration)
| Function | Address | Purpose |
|---|---|---|
| SpatialTree_ctor | 0x463330 | Create tree from object list |
| SpatialTree_SetDefaults | 0x4632F0 | Reset tree parameters |
| SpatialTree_Free | 0x4632E0 | Free tree memory |
| SpatialTree_DeletingDtor | 0x4633B0 | Destructor |
| SpatialTree_ForEach | 0x463880 | Iterate leaves |
| SpatialTree_CloneToLevel | 0x462380 | Clone for collision testing |
| SpatialTree_ComputeOffset | 0x466480 | Compute spatial offset |
| Collision_TraverseSpatialTree | 0x465EF0 | Main collision query |
SpatialTree is an AABB tree used for O(log N) collision detection.
Ball_Update creates one per frame: SpatialTree_ctor(mesh_list), then
Collision_TraverseSpatialTree to find collision candidates.
🔗 Related Documents
Level-Locked Objects & Spawn M
Level-Locked Objects & Spawn Methods
Hamsterball's object factory (CreateLevelObjects at 0x4121D0) is the central dispatcher that reads named objects from the MESHWORLD file and creates game objects. However, several object types are gated behind two independent mechanisms:
- Tournament mode gate (
app+0x23C != 0) — prevents objects from spawning in non-tournament/non-arena modes - Scene mesh pointer dependency — object constructors reference pre-loaded mesh pointers that only exist if the specific
BoardLevel*_ctorloaded the sub-mesh file
This document covers which objects are locked, why, and how to spawn them anyway.
Object Categories
Category 1: Self-Loading Objects (spawn anywhere)
These objects load their own MeshWorld from a hardcoded file path inside their constructor. They create their own CollisionLevel internally. The only barrier is the app+0x23C tournament flag gate.
| Object | Factory Match | Constructor | Address | Loads File | Struct Size | Gate |
|---|---|---|---|---|---|---|
| Bonk | "BONK" |
Bonk_ctor |
0x438850 |
levels\level5-bonk |
0x1200 | app+0x23C != 0 |
| Bumper | "N:BUMPER%d" |
CreateBumper |
0x40FA20 |
levels\level8 |
0x10D0 | None |
| Bumper2 | "N:BUMPER%d" |
CreateBumper2 |
0x413CE0 |
levels\arena-beginner |
0x10D0 | None |
| Bridge | "BRIDGE" |
(inline in factory) | 0x4121D0 |
Uses scene+0x436C/0x4370 |
— | None |
| BreakBridge1 | "BBRIDGE1" |
BreakBridge_ctor |
0x436D70 |
Uses scene+0x5410 |
0x1100 | None |
| BreakBridge2 | "BBRIDGE2" |
BreakBridge_ctor |
0x436D70 |
Uses scene+0x5414 |
0x1100 | None |
| PopCylinder | "POPCYLINDER" |
PopCylinder_ctor |
0x436EE0 |
Uses scene+0x5420 |
0x10E8 | None |
Category 2: Scene-Dependent Objects (level-locked)
These constructors receive a pre-loaded mesh pointer as a parameter. That pointer only exists if the specific level constructor loaded the sub-mesh file. Without the pointer, calling the constructor will crash.
| Object | Factory Match | Constructor | Address | Needs Scene Offset | Mesh File | Loaded By | Gate |
|---|---|---|---|---|---|---|---|
| Tipper | "TIPPER" |
Tipper_ctor |
0x437960 |
+0x4394 (mesh), +0x4398 (visual) |
Levels\Level3-Tipper |
LevelBoard_Dizzy_ctor (0x41D060) |
app+0x23C != 0 |
| Gluebie | "GLUEBIE" |
Gluebie_ctor |
0x437CB0 |
+0x607C (mesh) |
Levels\Level3-Gluebie |
LevelBoard_Dizzy_ctor (0x41D060) |
app+0x23C != 0 |
| BlockDawg1 | "BLOCKDAWG1" |
Blockdawg_ctor |
0x43C310 |
+0x5840 (mesh) + "DAWGPATH1" named object |
(dawg sub-mesh) | Level ctors | app+0x23C != 0 |
| BlockDawg2 | "BLOCKDAWG2" |
Blockdawg_ctor |
0x43C310 |
+0x5844 (mesh) + "DAWGPATH2" named object |
(dawg sub-mesh) | Level ctors | app+0x23C != 0 |
| Catapult | "CATAPULT" |
Catapult_ctor |
0x437E10 |
+0x5848 (mesh) |
(catapult sub-mesh) | Level ctors | None |
Spawning Self-Contained Objects (No MESHWORLD Edit Needed)
BadBalls (8-balls / AI opponents)
BadBalls are spawned by CreateBadBall which is called from Scene_SpawnBallsAndObjects. They do NOT go through CreateLevelObjects — they scan ALL MESHWORLD section 3 objects for names starting with "BADBALL".
CreateBadBall — Address: 0x0040BCA0
-
Convention:
__fastcall(MSVC), first arg = Scene* (param_1) -
Parameters:
param_1= Scene* -
MESHWORLD iteration: Walks
scene+0x8AC → +0x480 → +0xCA0object array, checks__strnicmp(name, "BADBALL", 7) -
MESHWORLD tag format (in object name string):
BADBALL<CHASE>25.0</CHASE><HOME>5.0</HOME><SIZE>2.0</SIZE>Tag Default Offset in Ball Description <CHASE>val</CHASE>25.0 ball+0xC6CAI chase activation distance (piVar5[0x31b]) <HOME>val</HOME>5.0 ball+0xC70AI patrol/home radius (piVar5[0x31c]) <SIZE>val</SIZE>1.0 ball+0x188Base radius → 3.0 (piVar5[0x62]=0x40400000=3.0f, piVar5[0xa1]=size_val, piVar5[0x9f]=0, piVar5+0x313 flag=1) <SPINDISTANCE>val</SPINDISTANCE>(none) ball+0xC7CAI spin orbit distance (piVar5[799]) -
Per-ball spawn sequence (from decompilation):
operator_new(0xC98)— allocate 8-ball (larger than player's 0xC60)Ball_ctor(this, scene)— initialize ball with scene referencevtable[1]()— call Ball_Init (2nd virtual)- Position from MESHWORLD object:
ball+0x164 = obj.x + radius,ball+0x168 = obj.y + ball.radius,ball+0x16C = obj.z + radius - Clear
ball+0x281 = 0(dead flag, never read) - Copy same position to home:
ball+0xC60/0xC64/0xC68 = obj.xyz(spawn/return position) - Parse
<CHASE>,<HOME>,<SIZE>,<SPINDISTANCE>tags from name string viaMWParser_ReadTag AthenaList_Append(scene+0x29D4, ball)— add to bad_balls listAthenaList_Append(scene+0x2DEC, ball)— add to all_balls list
-
⚠️ MESHWORLD dependency:
CreateBadBallreads position AND tags from MESHWORLD section 3 object entries. You CANNOT call it directly with a custom position — it iterates the object array internally.
Spawning a BadBall without MESHWORLD (manual spawn)
You can bypass CreateBadBall entirely and construct a BadBall manually by replicating what it does. Here is a code-level pseudocode for spawning at an arbitrary position:
// Manual BadBall spawn — no MESHWORLD needed
// Call from a hook or patched function with access to Scene* (param_1)
void* scene = (void*)param_1; // Scene pointer
// 1. Allocate
void* ball = operator_new(0xC98);
// 2. Construct
Ball_ctor(ball, scene); // 0x4087A0 (base Ball_ctor, NOT Ball_Split_ctor)
// 3. Init (vtable[1])
((void(__thiscall*)(void*))(*(void***)ball)[1])(ball);
// 4. Set position (ball+0x164/168/16C = display position)
*(float*)(ball + 0x164) = spawn_x;
*(float*)(ball + 0x168) = spawn_y;
*(float*)(ball + 0x16C) = spawn_z;
// 5. Set home position (ball+0xC60/C64/C68 = return-to position)
*(float*)(ball + 0xC60) = spawn_x;
*(float*)(ball + 0xC64) = spawn_y;
*(float*)(ball + 0xC68) = spawn_z;
// 6. Set AI flag
*(byte*)(ball + 0x281) = 0; // dead flag (never read by any function)
// 7. Set chase distance (optional, default 25.0)
*(float*)(ball + 0xC6C) = 25.0f;
// 8. Set home radius (optional, default 5.0)
*(float*)(ball + 0xC70) = 5.0f;
// 9. Set size (optional, default 1.0 = normal)
// To make giant: *(float*)(ball + 0x188) = 3.0f; *(byte*)(ball + 0xC4C) = 1;
// 10. Register in scene lists
AthenaList_Append(scene + 0x29D4, ball); // bad_balls
AthenaList_Append(scene + 0x2DEC, ball); // all_balls
Key addresses for manual spawn:
| Step | Function/Offset | Address |
|---|---|---|
| Allocate | operator_new(0xC98) |
runtime |
| Ball_ctor | Ball_ctor(ball, scene) |
0x40AFE0 |
| Ball_ctor2 | Ball_ctor2(ball, scene) (base init only) |
0x4039E0 |
| Ball_Split_ctor | Ball_Split_ctor(ball, scene) (8-ball variant) |
0x408D10 |
| Ball_Init | vtable[1]() |
via vtable |
| Display pos | ball+0x164/0x168/0x16C |
— |
| Home pos | ball+0xC60/0xC64/0xC68 |
— |
| AI flag clear | ball+0x281 |
DEAD: never read |
| Chase dist | ball+0xC6C |
— |
| Home radius | ball+0xC70 |
— |
| Add to bad_balls | AthenaList_Append(scene+0x29D4, ball) |
— |
| Add to all_balls | AthenaList_Append(scene+0x2DEC, ball) |
— |
Alternatively, for split-ball 8-balls (from Ball_Shatter at 0x408D70):
-
Uses
Ball_Split_ctorat0x408D10(which callsBall_ctor2then setsball+0xC60=5) -
Allocates 0xC64 bytes (slightly smaller than CreateBadBall's 0xC98)
-
Sets
ball+0x31D = 1(is_8ball),ball+0xC60 = 0x41200000(30.0f, split timer) -
Copies collision direction from parent at
ball+0xCA4/0xCA8/0xCAC -
Sets split ID: 1, 2, or 4 (
ball+0x324) -
To add BadBalls via MESHWORLD: Add objects of type 1 in section 3 with names like
BADBALL<CHASE>30</CHASE><SIZE>3</SIZE>. They will be spawned automatically on level load whenapp+0x23C != 0(tournament mode).
MouseTraps
MouseTraps are spawned by CreateMouseTrap based on MESHWORLD object names starting with "N:MOUSETRAP".
CreateMouseTrap — Address: 0x0040BF50
- Convention:
__fastcall(param_1 = Scene*) - MESHWORLD iteration: Same pattern as
CreateBadBall— walksscene+0x8AC → +0x480 → +0xCA0object array, checks__stricmp(name, "MOUSETRAP")(case-insensitive) - Alloc size: 0x10F8 bytes per MouseTrap
- Constructor:
TipperVisual_Level_Ctor(this, scene)at0x437880 - Position copied from object:
obj+0x10DC/0x10E0/0x10E4 = obj.x/y/z - Floor offset:
obj+0x10F4 = _DAT_004CF44C - obj.rot_y(constant minus Y rotation for floor height) - Post-init: Calls
vtable[0x2C]()(virtual function at offset 0xB0 in vtable, likely Activate) - Scene storage:
AthenaList_Append(scene+0xCD4, obj)— mouse_trap_listAthenaList_Append(scene+0x1930, obj)— physics_objectsAthenaList_Append(scene+0x8AC→0x480→0x1C, obj)— level_objectsAthenaList_Append(scene+0x8B0+0x18, obj+0x10D4)— some sub-component
- Gate: None (not gated by
app+0x23C) - Scene mesh dependency: The constructor calls
TipperVisual_Level_Ctor(scene)— readsscene+0x4398(Tipper CollisionLevel). Must be loaded by BoardLevel3 ctor.
Manual spawn:
void* mem = operator_new(0x10F8);
void* trap = TipperVisual_Level_Ctor(mem, scene);
*(float*)(trap + 0x10DC) = obj.x; // position
*(float*)(trap + 0x10E0) = obj.y;
*(float*)(trap + 0x10E4) = obj.z;
*(float*)(trap + 0x10F4) = _DAT_004CF44C - obj.rot_y; // floor height
((void(__thiscall*)(void*))(*(void***)(trap))[0x0B])(trap); // Activate
AthenaList_Append(scene + 0xCD4, trap); // mouse_trap_list
AthenaList_Append(scene + 0x1930, trap); // physics_objects
Sawblades (and Arena Object Sub-Dispatcher)
⚠️ CreateExpertLevelObjects is actually a multi-factory, not just a sawblade creator. It handles 6 different arena objects based on the name prefix. It is called from CreateLevelObjects for each section-3 object whose name doesn't match any other prefix.
CreateExpertLevelObjects — Address: 0x0040E250
- Convention:
__thiscall(ECX = Scene*, param_1 = name string, param_2/3 = output ptrs, param_4 = transform) - Called from:
Scene_HandleCollisions/CreateLevelObjects - Gate: All sub-objects with
app+0x23C != 0check: BONK, TIP, SAWBLADE
Sub-dispatcher branches:
| Prefix | Object Type | Alloc Size | Constructor | Notes |
|---|---|---|---|---|
BONK |
Bonk hammer | 0x1200 | Bonk_ctor at 0x438850 |
Reads position from param_4+4/8/C; gate: app+0x23C != 0 |
TIP |
Tower (swinging arm) | 0x1188 | TowerLevel_Ctor |
Checks SLOW, SUPER, UP suffixes; gate: app+0x23C != 0 |
SAWBLADE |
Spinning saw | 0x111C | Sawblade_Level_Ctor |
Suffix 1 → scene+0x4370, suffix 2 → scene+0x4374; gate: app+0x23C != 0 |
BRIDGE |
Spinner platform | 0x10FC | Spinner_Level_ctor |
Suffix 1 → scene+0x4380, suffix 2 → scene+0x4798, suffix NEG → obj+0x10F8 = -1.0f |
JUDGE |
Gear | 0x1100 | Gear_Level_ctor |
Appended to scene+0x4BBC |
BELL |
Bell (Tipper variant) | 0x10E8 | Tipper_Level_Ctor |
Appended to scene+0x2578; stored at scene+0x4FD4 |
All created objects get appended to scene+0x2578 (active objects list).
SAWBLADE details:
SAWBLADE1→ stored atscene+0x4370, callsSawblade_SetBreakSound(1)SAWBLADE2→ stored atscene+0x4374, callsSawblade_SetBreakSound(2)- Triggered by collision events
E:ALERTSAW1/E:ACTIVATESAW1
TIP details:
TIPSLOW→obj+0x10EC = 1TIPSUPER→obj+0x10ED = 1TIPUP→ callsSound_InitChannels(obj, 1)
BRIDGE spinner details:
BRIDGE1→ appended toscene+0x4380BRIDGE2→ appended toscene+0x4798BRIDGENEG→ sets spin direction to -1.0f atobj+0x10F8
Bonks (Hammer popups)
Bonk_ctor — Address: 0x00438850
- Convention:
__thiscall(ECX = this, allocated buffer) - Parameters:
this(void*),scene(int),x(float),param_3(undefined4),param_4(undefined4) - Self-loads:
MeshWorld_ctor(this, graphics, "levels\\level5-bonk") - Creates own collision:
CollisionLevel_ctorWithLevel(collision, this) - Size: 0x1200 bytes
- Scene storage: Result stored at
scene+0x540C - Gate:
app+0x23C != 0
This is the easiest level-locked object to unlock. Since Bonk_ctor loads its own mesh file (levels\level5-bonk), it has no scene mesh pointer dependency. The ONLY thing preventing Bonks from appearing in any level is the app+0x23C check at 0x4121D0.
To add Bonks:
- Add object named
"BONK_01"(or any BONK-prefixed name) in MESHWORLD section 3 - Ensure
app+0x23C != 0OR patch the gate
Bumpers (⚠️ Level-replacement hazard)
CreateBumper — Address: 0x0040FA20
- Convention:
__fastcall(param_1 = Scene* asint*) - ⚠️ CRITICAL GOTCHA: This does NOT just spawn bumper objects. It loads the entire
levels\level8MESHWORLD file as a new MeshWorld, creates a CollisionLevel from it, callsLevel_InitScene, then scans for 8N:BUMPER%dnamed objects. This replaces the level's scene data — the current level's meshes get overwritten atscene+0x22B(MeshWorld) andscene+0x22C(CollisionLevel). Calling CreateBumper on non-Level8 levels will crash or corrupt the scene because it overwrites the active level's collision/geometry. - Self-loads:
MeshWorld_ctor(buf, graphics, "levels\\level8")→ stored atparam_1[0x22B] - CollisionLevel:
CollisionLevel_ctorWithLevel(buf2, meshWorld)→ stored atparam_1[0x22C] - Init:
Level_InitScene(param_1)— reinitializes scene with new level data - 8× bumpers:
Scene_CollectByNameFilter("N:BUMPER%d", ...)at stride 0x106 (0x10E3 base) - Post-init:
(*param_1)[0x80]()— calls vtable offset 0x80 (virtual function, likely level-specific setup) - Size: 0x10D0 bytes per allocation
- Gate: None
CreateBumper2 — Address: 0x00413CE0
- Same pattern but loads
levels\arena-beginner— also replaces scene data - Size: 0x10D0 bytes
- Gate: None
Bottom line: Both CreateBumper variants are level-scoped constructors, not standalone object spawners. They work correctly only when called from within a BoardLevel8 or Arena board constructor that expects this level-swap behavior. Calling them from another level context will corrupt the scene.
Ball Splits (8-ball arena mechanic)
Ball_Shatter — Address: 0x00408D70
- Convention:
__thiscall(ECX = Ball*) - Prologue: Standard SEH (
PUSH -1; MOV EAX, FS:[0]; PUSH handler) - Parameters:
this(Ball* — the parent ball),param_1(struct with target position table) - Called from:
FollowBall_Update(0x43ECC0) at two call sites (0x43F722, 0x43FE36) - NOT called from E:JUMP. The E:JUMP collision handler (in collision_events.c) does something different: plays a 3D sound, applies upward force, and adds +200 score. See docs/collision_events.
- Behavior: Marks the parent ball for despawn (+0x2E8=1), then creates 3 new
Ball_Splitobjects (0xC64 bytes each) viaBall_Split_ctor(0x408D10). Each split ball:ball+0x31D = 1(is_8ball flag, activates AI behavior)ball+0xC6 = 0x41200000(10.0f — split ball size, NOT 30.0f)- Split IDs: 1, 2, 4 (bitmask, assigned by iteration index)
- Trajectory copied from parent's
+0x2AC–0x2B8 - Position from parent or lookup table via
param_1 - Added to
scene+0x3204(ball list)
- Guard conditions:
+0x324 == 0(only non-8ball-type balls can split)+0x744 == 0(only split once — split_count must be zero)
Ball Init Battle Mode
Ball_InitBattleMode — Address: 0x00456CD0
- Convention:
__thiscall(ECX = Ball*) - Prologue:
SUB ESP, 0C; PUSH ESI; LEA EAX, [ECX+0xC80]; ... - Sets ball to arena/battle physics:
| Offset | Value | Description |
|---|---|---|
+0xC60 |
3 | battle_mode = 3 |
+0xC68 |
0.55 | friction |
+0xC6C |
1.0 | bounciness |
+0xC70 |
1000.0 | max_speed |
+0xC74 |
25.0 | chase_distance |
+0xC80 |
0.0 | gravity_vec[0] |
+0xC84 |
-1.0 | gravity_vec[1] |
+0xC88 |
0.0 | gravity_vec[2] |
Additional Spawn Functions (Not in CreateLevelObjects)
These functions are called from Scene_SpawnBallsAndObjects (0x41C5B0) AFTER the main level objects are created. They don't go through the object factory dispatcher.
CreateSecretObjects
Address: Unknown (not yet decompiled)
- Called from:
Scene_SpawnBallsAndObjectsat0x41C5B0 - Purpose: Creates hidden collectible objects (secret items/bonuses)
- Likely scans: MESHWORLD for objects with
SECRETor similar prefix - Gate: Unknown — may be gated by
app+0x23C
Scene_CreateFlags
Address: Unknown (not yet decompiled)
- Called from:
Scene_SpawnBallsAndObjectsat0x41C5B0 - Purpose: Creates checkpoint/finish-line flag objects
- Likely scans: MESHWORLD for
FLAGorFINISHnamed objects
Scene_CreateSigns
Address: Unknown (not yet decompiled)
- Called from:
Scene_SpawnBallsAndObjectsat0x41C5B0 - Purpose: Creates directional arrow sign objects
- Likely scans: MESHWORLD for
SIGNorARROWnamed objects
Scene_CreateDynamicObjects
Address: Unknown (not yet decompiled)
- Called from:
Scene_SpawnBallsAndObjectsat0x41C5B0 - Purpose: Creates moving/animated objects (platforms, elevators, etc.)
- Likely scans: MESHWORLD for
DYNAMICorMOVINGnamed objects
The app+0x23C Tournament Gate
The single variable that gates most level-locked objects. Located at App+0x23C (App singleton at 0x4FD680, so absolute address 0x4FD8B4).
Objects gated by app+0x23C != 0:
TIPPER(at0x4121D0+0x3A— JNZ after CMP)BONK(at0x4121D0+0xAA— JNZ after CMP)BLOCKDAWG1(at0x4121D0+0x1E2— JNZ after CMP)BLOCKDAWG2(at0x4121D0+0x235— JNZ after CMP)GLUEBIE(inverted: at0x4121D0+0x2F0— JZ after CMP, skips if0x23C == 0)CreateBadBall(spawned fromScene_SpawnBallsAndObjects0x41C5B0)
How to bypass:
- Memory patch: Write
1to address0x4FD8B4at runtime - Binary patch: In
CreateLevelObjectsat0x4121D0, patch each conditional jump after theapp+0x23Ccomparison:- Find the
CMP [addr], 0+JNZ/JZsequences - Replace
JNZwithJMP(unconditional) to always take the branch - For GLUEBIE's inverted check, replace
JZwithJMP(or NOP out the skip)
- Find the
Scene Mesh Dependency Reference
For scene-dependent objects, here is exactly what each level constructor loads and where it stores the mesh pointers:
LevelBoard_Dizzy_ctor (0x41D060) — Dizzy Race
Loads the most sub-meshes of any level:
| Scene Offset | Mesh File | Used By |
|---|---|---|
+0x436C |
Levels\Level3-Tipper (MeshWorld) |
BRIDGE collision mesh, TIPPER visual |
+0x4370 |
(CollisionLevel from Tipper) | Collision against tipper geometry |
+0x4374 |
Levels\Level3-Gluebie (MeshWorld) |
GLUEBIE visual |
+0x4BA8 |
Levels\Level3-WaterWheel (MeshWorld) |
Level3-specific water wheel |
+0xBAC |
(CollisionLevel from WaterWheel) | Water wheel collision |
+0x4BC4 |
Levels\Level3-Swirl (MeshWorld) |
Level3-specific swirl |
+0xBC8 |
(CollisionLevel from Swirl) | Swirl collision |
LevelBoard_Expert_ctor (0x41EA40)
| Scene Offset | Mesh File | Used By |
|---|---|---|
+0x4378 |
Levels\Level5-Bridge (MeshWorld) |
BRIDGE collision mesh |
+0x437C |
(CollisionLevel from Bridge) | Bridge collision |
LevelBoard_Beginner_ctor (0x4200E0) — Beginner Race (Arena, internal name: Cascade)
| Scene Offset | Content | Used By |
|---|---|---|
+0x436C |
Vec3List array (0x418 bytes, 8 items) | Bumper creation data |
LevelBoard_WarmUp_ctor (0x41CA40) — Warm-Up
No sub-meshes loaded. All scene mesh pointer offsets (+0x4394, +0x5840, +0x607C, etc.) are NULL/zero from the base Board_ctor.
How to Add Level-Locked Objects to Any Level
For self-loading objects (BONK, Bumper, BadBall):
Step 1: Patch app+0x23C to 1 (for BONK and BadBall) or just add MESHWORLD names (for Bumper — no gate).
Step 2: Add named objects to the level's MESHWORLD file section 3:
- For BONK: add objects named
"BONK_01","BONK_02", etc. with their position - For BadBall: add type-1 objects named
"BADBALL<CHASE>25</CHASE><SIZE>2</SIZE>" - For Bumper: add objects named
"N:BUMPER1","N:BUMPER2", etc.
For scene-dependent objects (TIPPER, GLUEBIE, BLOCKDAWG, CATAPULT):
Step 1: Patch app+0x23C to 1
Step 2: Patch the level's BoardLevel*_ctor to load the required sub-mesh files and store the MeshWorld/CollisionLevel pointers at the correct scene offsets. For example, to add TIPPER support to Level 1:
- Patch
LevelBoard_WarmUp_ctorat0x41CA40to add:MeshWorld_ctor(buf, graphics, "Levels\\Level3-Tipper") store result → scene+0x4394 CollisionLevel_ctorWithLevel(buf2, tipper_mesh) store result → scene+0x4398 - This requires injecting code (e.g., at the end of the constructor before the return) that allocates, constructs, and stores the mesh pointers.
Step 3: Add TIPPER named objects to Level 1's MESHWORLD section 3 (e.g., "TIPPER_A").
Alternative: Reimplementation approach
In a reimplementation, bypass both gates entirely:
- Remove all
app+0x23C != 0checks fromCreateLevelObjects - Make all constructors self-loading (like
Bonk_ctordoes) — callMeshWorld_ctorwith the appropriate file path inside each constructor - Load sub-meshes on demand rather than only in level constructors
Function Address Quick Reference
| Function | Address | Purpose |
|---|---|---|
CreateLevelObjects |
0x4121D0 |
Main factory dispatcher — matches MESHWORLD names to constructors |
CreateExpertLevelObjects |
0x40E250 |
Multi-factory: BONK, TIP, SAWBLADE, BRIDGE, JUDGE, BELL sub-dispatcher |
Scene_SpawnBallsAndObjects |
0x41C5B0 |
Ball spawning on level load |
CreateBadBall |
0x40BCA0 |
Spawn 8-ball AI opponent from MESHWORLD BADBALL tag |
CreateMouseTrap |
0x40BF50 |
Spawn mouse trap from MESHWORLD MOUSETRAP objects |
Ball_Shatter |
0x408D70 |
Arena 8-ball split: replaces parent ball with 3 AI split balls |
Ball_InitBattleMode |
0x456CD0 |
Set ball to battle/arena physics |
Bonk_ctor |
0x438850 |
Bonk constructor (self-loads levels\level5-bonk) |
CreateBumper |
0x40FA20 |
Bumper factory (self-loads levels\level8) — ⚠️ REPLACES scene mesh |
CreateBumper2 |
0x413CE0 |
Arena bumper factory (self-loads levels\arena-beginner) |
TowerLevel_Ctor |
(in 0x40E250) |
Tower/swinging arm constructor (TIP prefix) |
Sawblade_Level_Ctor |
(in 0x40E250) |
Sawblade constructor (SAWBLADE prefix) |
Spinner_Level_ctor |
(in 0x40E250) |
Spinner platform constructor (BRIDGE prefix) |
Gear_Level_ctor |
(in 0x40E250) |
Gear constructor (JUDGE prefix) |
Tipper_Level_Ctor |
(in 0x40E250) |
Bell/Tipper variant constructor (BELL prefix) |
TipperVisual_Level_Ctor |
0x437880 |
Mouse trap visual constructor |
Tipper_ctor |
0x437960 |
Tipper constructor (needs scene+0x4394) |
Gluebie_ctor |
0x437CB0 |
Gluebie constructor (needs scene+0x607C) |
Catapult_ctor |
0x437E10 |
Catapult constructor (needs scene+0x5848) |
Blockdawg_ctor |
0x43C310 |
BlockDawg constructor (needs scene+0x5840 + path obj) |
BreakBridge_ctor |
0x436D70 |
Breakable bridge constructor |
PopCylinder_ctor |
0x436EE0 |
Pop cylinder constructor |
CreateSecretObjects |
Unknown | Hidden collectibles (called from Scene_SpawnBallsAndObjects) |
Scene_CreateFlags |
Unknown | Finish line flags (called from Scene_SpawnBallsAndObjects) |
Scene_CreateSigns |
Unknown | Directional signs (called from Scene_SpawnBallsAndObjects) |
Scene_CreateDynamicObjects |
Unknown | Moving platforms, etc. (called from Scene_SpawnBallsAndObjects) |
Level_FindObjectByName |
0x460530 |
Find MESHWORLD object by name |
ExpertCollisionEvents |
0x40E6A0 |
Arena event dispatcher (HAMMERCHASE, ALERTSAW, etc.) |
operator_new |
0x4BA57B |
Game's CRT allocator (__cdecl, size on stack) |
App singleton |
0x4FD680 |
Global App struct |
app+0x23C |
0x4FD8B4 |
Tournament mode flag |
Generated from Ghidra 12.0 decompilation. See docs/MODDING_FUNCTION_REFERENCE.md for full function details and docs/OBJECT_CATALOG.md for object listing.
🔗 Related Documents
Mace, Windmill & Chomper Syste
Mace, Windmill & Chomper System (Tower Race)
Complete reverse-engineering analysis of the Mace (Pendulum), Windmill, and Chomper systems in the Tower Race.
Overview
The Tower Race features three notable mechanical objects:
- MACE (Pendulum) — Swinging mace on a chain that bounces balls
- WINDMILL — Rotating collision level (pushes balls away)
- CHOMPER — Visual-only mesh, NOT a separate game object. The actual object is the TURRET.
All three are created inline in Scene_LoadLevel4 (0x40D6D0), not via a standalone factory function.
MACE (Pendulum / Swinging Mace)
Binary Addresses
| Function | Address | Calling Convention | Purpose |
|---|---|---|---|
| CascadeStands_Ctor | 0x438750 | thiscall(obj, board, mesh) ret 8 | Constructor — calls Stands_ctor + allocates CollisionLevel |
| Pendulum_Update | 0x43F3C0 | thiscall(obj) | vtable[11] — swing animation + collision |
| Pendulum_Render | 0x45E0E0 | thiscall(obj) | vtable[18] — shared base render |
| Pendulum_scalar_dtor | 0x438830 | thiscall(obj) | vtable[0] — destructor |
| Stands_ctor | 0x462850 | thiscall(obj, mesh) ret 4 | Base class constructor — sets vtable 0x4D8FB0 |
| SceneObject_SpawnWithSound | 0x4536A0 | — | Spawns scene object with sound |
| Level_FindObjectByName | 0x4C7677 | cdecl ret 12 | Finds mesh ref by name in loaded level |
Object Structure (0x110C bytes = 4364)
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x0000 | 4 | ptr | Vtable (0x4D50C0) |
| +0x10D0 | 4 | ptr | Board pointer |
| +0x10D4 | 4 | float | Home position X (from mesh) |
| +0x10D8 | 4 | float | Current position X |
| +0x10DC | 4 | float | Current position Y |
| +0x10E0 | 4 | float | Current position Z |
| +0x10E8 | 4 | float | Swing amplitude (80.0) — oscillation radius |
| +0x10EC | 4 | — | Unused |
| +0x10F0 | 4 | int | Activation flag (0=idle, 1=triggered by E:MACETRIGGER) |
| +0x10F4 | 4 | int | Active flag (1 = active) |
| +0x10F8 | 4 | int | Timer (init 50) |
| +0x10FC | 4 | ptr | CollisionLevel pointer (allocated by CascadeStands_Ctor) |
| +0x1100+ | — | — | CollisionLevel data (0x10D0 bytes) |
Vtable (0x4D50C0)
| Index | Offset | Address | Function |
|---|---|---|---|
| 0 | 0x00 | 0x438830 | scalar_dtor |
| 11 | 0x2C | 0x43F3C0 | Pendulum_Update (swing + collision) |
| 18 | 0x48 | 0x45E0E0 | Render (shared base) |
Creation Flow
Inline in Scene_LoadLevel4 (0x40D6D0):
// 1. Find "MACE" mesh ref in loaded level
mesh_ref = Level_FindObjectByName("MACE");
if (mesh_ref == NULL) skip;
// 2. Allocate
obj = operator_new(0x110C);
// 3. Construct
// CascadeStands_Ctor(this=obj, board, *(board+0x4378))
// → Stands_ctor(obj, mesh) → sets vtable = 0x4D50C0
// → operator_new(0x10D0) → CollisionLevel_ctorWithLevel(coll, obj)
mesh_ptr = *(board + 0x4378); // pre-loaded Level4-Mace mesh
CascadeStands_Ctor(obj, board, mesh_ptr); // thiscall, ret 8
// 4. Copy position from mesh ref to obj+0x10D8/+0x10DC/+0x10E0
obj->pos = mesh_ref->pos;
// 5. Register to lists
AthenaList_Append(board+0x2578, obj); // general objects
AthenaList_Append(board+0x5000, obj); // mace list (for E:MACETRIGGER)
// 6. Set fields
obj->swing_amplitude = 80.0; // +0x10E8
obj->active = 1; // +0x10F4
obj->timer = 50; // +0x10F8
Update Function (vtable[11] @ 0x43F3C0)
void Pendulum_Update(this) {
// Base update (collision tree check)
call 0x4605E0; // Level_Update base
call 0x457AD0; // Timer_Init
call 0x457C60; // Timer_Cleanup
call 0x40A0B0; // scene operation
// Load position for collision
FLD [this+0x10D8] // pos X
FLD [this+0x10DC] // pos Y
FLD [this+0x10E0] // pos Z
call 0x457A40; // timer operation
// Swing animation
FLD [this+0x10E8] // load amplitude (80.0)
MOV [this+0x10E8], 0.05 // temporarily set to 0.05 (angle increment)
FLD [this+0x10E8] // load increment
// Trig calculation (sin/cos for oscillation)
FLD [this+0x10E0] // pos Z
FLD [this+0x10DC] // pos Y
call 0x459860; // trig function
MOV [this+0x10E8], 80.0 // restore amplitude
call 0x43F559; // collision check sub-function
RET;
}
The pendulum oscillates around its position. The 80.0 amplitude defines the swing radius, and 0.05 is the angle increment per frame (about 2.86°/frame ≈ 71.5°/sec at 25fps).
Collision Events
Handled in Level_HandleCollision (0x40DCD0):
// E:MACETRIGGER — activates all maces in board+0x5000
if (stricmp(eventName, "E:MACETRIGGER") == 0) {
for each mace in board+0x5000:
mace->activation_flag = 1; // +0x10F0
}
// N:MACE — ball touches mace, bounce away
if (stricmp(eventName, "N:MACE") == 0) {
for each mace in board+0x5000:
if (mace->collides_with_ball && boundary_check)
ball->vtable[8](); // bounce/split
}
Mesh Dependency
Pre-loaded by LevelBoard_Tower_ctor (0x41E340):
board+0x4378 = MeshWorld_ctor("Levels\\Level4-Mace"); // VA 0x4D0974
For global spawn: load mesh via MeshWorld_ctor with JIT mesh injection at board+0x4378.
WINDMILL
Binary Addresses
| Function | Address | Purpose |
|---|---|---|
| CollisionLevel_ctorWithLevel | 0x465080 | Creates collision level from mesh |
| Level_LoadMeshes | 0x465200 | Loads mesh vertex/index data |
| SceneObject_SetupCallback | 0x45DD60 | Registers collision with scene manager (stdcall, ret 8) |
Object Structure
The Windmill is NOT a standalone game object. It creates:
- CollisionLevel (0x10D0 bytes) — collision-only, no vtable/update/render
- Trapdoor (0x10F8 bytes) — separate object via GlassStands_Ctor
Creation Flow
// 1. Find "WINDMILL" mesh ref
mesh_ref = Level_FindObjectByName("WINDMILL");
if (mesh_ref == NULL) skip;
// 2. Get mesh from board+0x437C (pre-loaded by Tower ctor)
mesh_ptr = *(board + 0x437C); // Level4-Windmill
// 3. Create CollisionLevel
coll = operator_new(0x10D0);
CollisionLevel_ctorWithLevel(coll, mesh_ptr); // thiscall, ret 4
// 4. Load mesh data
Level_LoadMeshes(coll);
// 5. Copy position from mesh ref
coll->pos = mesh_ref->pos;
// 6. Register with scene manager
SceneObject_SetupCallback(0x4F7360, 0x168, 0); // global scene + callback type
// 7. Store angle at board+0x438C
board->windmill_angle = (float)result;
// 8. Create Trapdoor
trapdoor_mesh = Level_FindObjectByName("TRAPDOOR");
trapdoor = operator_new(0x10F8);
GlassStands_Ctor(trapdoor, mesh_ptr);
Why Windmill Can't Be Spawned Globally
The Windmill is a CollisionLevel — a collision shape without:
- A game object vtable
- An update function (vtable[11])
- A render function (vtable[18])
The visual windmill mesh is part of the level's static geometry (rendered by the scene's spatial tree). The CollisionLevel only provides collision detection that pushes balls away when they touch it.
To spawn a windmill globally, you would need to:
- Create the CollisionLevel (pushes balls)
- Also spawn a visual mesh at the same position
- Register both with the scene manager
This is significantly more complex than spawning a Mace.
CHOMPER (Purple Thing in the Pit)
The Chomper is the purple thing sitting in the pit near the goal in the Tower Race. It DOES interact with the ball — when the ball touches it, the E:BITE collision event fires, dealing 25.0 damage.
What It Actually Is
The Chomper is a MeshWorld + CollisionLevel (not a game object with vtable). It's rendered as static geometry and has collision via a CollisionLevel. The E:BITE events are baked into the mesh's collision triangles — they fire automatically wherever the chomper is placed.
Binary Addresses
| Symbol | Address | Purpose |
|---|---|---|
| "Meshes\Chomper" | 0x4D094C | Mesh file path string |
| "sounds\chomp" | 0x4D2D98 | Chomp sound file path |
| "E:BITE" | 0x4CFECC (→25.0 at 0x41C80000) | Collision event on chomper triangles |
Creation Flow (Tower Constructor @ 0x41E340)
// 1. Load Chomper mesh
board+0x4390 = MeshWorld_ctor("Meshes\\Chomper"); // VA 0x4D094C
// 2. Allocate CollisionLevel
coll = operator_new(0x10D0);
CollisionLevel_ctorWithLevel(coll, board+0x4390); // creates collision from mesh
// 3. Register with scene manager (0x4F7360) for collision detection
SceneObject_SetupCallback(0x4F7360, 0x168, 0);
Collision (E:BITE)
Handled in Level_HandleCollision (0x40DCD0):
// E:BITE — ball touches chomper
if (stricmp(eventName, "E:BITE") == 0) {
board+0x43A0 = 25.0; // bite damage (0x41C80000)
board+0x43A8 = 0; // reset bite counter
}
The damage value at board+0x43A0 is read by the game loop at 0x4023C1, which applies it to the ball (splits/respawns). The "chomp" sound plays on contact.
Position in Tower Level
From Level4.MESHWORLD: Chomper mesh ref at position (5080.9, -2659.6, -3410.8) — in the pit near the goal.
Spawnable: YES
The Chomper CAN be spawned globally using the same CollisionLevel pattern as the Windmill:
- Load
Meshes\Chompermesh viaMeshWorld_ctor - Create CollisionLevel from mesh via
CollisionLevel_ctorWithLevel - Register with scene manager
- E:BITE events are baked into the mesh → automatically work at any position!
TURRET (separate object, not Chomper)
The Turret is a separate game object that uses Level4-Turret mesh:
board+0x43B4 = MeshWorld_ctor("Levels\\Level4-Turret");
// Turret creation in Scene_LoadLevel4:
obj = operator_new(0x10D0);
Stands_ctor(obj, mesh); // sets vtable = 0x4D8FB0
Mesh Pre-loading (Tower Constructor)
board+0x436C = MeshWorld_ctor("Levels\\Level4-Catapult");
board+0x4370 = MeshWorld_ctor("Levels\\Level4-Drawbridge");
board+0x4374 = MeshNode_ctor("Meshes\\YellowLink");
board+0x4378 = MeshWorld_ctor("Levels\\Level4-Mace"); // Mace mesh
board+0x437C = MeshWorld_ctor("Levels\\Level4-Windmill"); // Windmill mesh
board+0x4390 = MeshWorld_ctor("Meshes\\Chomper"); // Chomper mesh + collision
board+0x43B4 = MeshWorld_ctor("Levels\\Level4-Turret"); // Turret mesh
Global Spawn: Mace
The Mace (Pendulum) is the most suitable for global spawning because it:
- Has its own vtable with update (swing) and render functions
- Has a standalone constructor that can be called with any mesh
- Has ball collision via N:MACE event
- Has activation via E:MACETRIGGER
JIT Mesh Injection Pattern
- Load
Levels\Level4-Macemesh viaMeshWorld_ctor→ cache atboard+0x4370 - On spawn:
- Save old
board+0x4378value - Write Mace mesh to
board+0x4378 operator_new(0x110C)CascadeStands_Ctor(obj, board, mesh)— reads fromboard+0x4378- Restore
board+0x4378 - Set position at
obj+0x10D8/+0x10DC/+0x10E0 - Set
obj+0x10E8 = 80.0(swing amplitude) - Set
obj+0x10F4 = 1(active) - Set
obj+0x10F8 = 50(timer) AthenaList_Append(board+0x2578, obj)(general list)AthenaList_Append(board+0x5000, obj)(mace list)
- Save old
- Per-frame: call
vtable[11](Pendulum_Update) for swing animation
Key Differences from Other Mods
- Alloc size: 0x110C (larger than Bonk's 0x1200 or Sawblade's 0x111C)
- Position offset: +0x10D8 (not +0x10D4 like most objects)
- Constructor: Takes 2 params (board + mesh), not just board
- Two lists: General (board+0x2578) AND mace list (board+0x5000)
- JIT mesh injection: Required because CascadeStands_Ctor reads from board+0x4378
🔗 Related Documents
MESH Binary Format Specificati
MESH Binary Format Specification
Overview
The .MESH format is Hamsterball's custom binary 3D model format. It is NOT a standard DirectX .X file — it's a proprietary format containing submeshes with materials, vertex buffers, index buffers, and animation data.
File Structure
[MESH File]
├── Header (version, name)
├── SubMesh Array (N submeshes)
│ ├── Submesh[0]: vertex buffer + material + textures
│ ├── Submesh[1]: ...
│ └── Submesh[N-1]: ...
├── Animation Data (M submesh animations)
├── Object Array (K objects with transforms)
└── Footer (transform data, index arrays)
Parsed Format (from decompiled MeshWorld loader at 0x4629E0)
Section 1: SubMeshes (Materials + Vertex Data)
[4 bytes] submesh_count (iStack_578)
FOR each submesh (i = 0..submesh_count-1):
[4 bytes] vertex_data_size (uStack_598)
[vertex_data_size bytes] raw_vertex_buffer (allocated, read in one block)
[4 bytes] field_1 (position X or similar)
[4 bytes] field_2 (position Y)
[4 bytes] field_3 (position Z)
[4 bytes] field_4
[4 bytes] field_5
[4 bytes] field_6
[4 bytes] has_material_flag (puVar5+10, checked as char)
IF has_material_flag != 0:
[4 bytes] diffuse_r (uStack_594)
[4 bytes] diffuse_g (uStack_590)
[4 bytes] diffuse_b (iStack_59c)
[4 bytes] diffuse_a (fStack_5a4) → puVar5[0x13]
[4 bytes] ambient_r → puVar5[0x10]
[4 bytes] ambient_g → puVar5[0x11]
[4 bytes] ambient_b → puVar5[0x12]
has_custom_color = (diffuse_a != _DAT_004CF3C8) // != default
[4 bytes] emissive_r → puVar5[0xC]
[4 bytes] emissive_g → puVar5[0xD]
[4 bytes] emissive_b → puVar5[0xE]
[4 bytes] emissive_a → puVar5[0xF]
[4 bytes] specular_r → puVar5[0x14]
[4 bytes] specular_g → puVar5[0x15]
[4 bytes] specular_b → puVar5[0x16]
[4 bytes] specular_a → puVar5[0x17]
[4 bytes] unknown_r → puVar5[0x18]
[4 bytes] unknown_g → puVar5[0x19]
[4 bytes] unknown_b → puVar5[0x1A]
[4 bytes] unknown_a → puVar5[0x1B]
[4 bytes] specular_power → puVar5[0x1C]
[4 bytes] has_texture_flag (iStack_554, bool stored at +0x79)
[4 bytes] texture_name_length (iStack_55C)
IF texture_name_length == 1:
[4 bytes] filename_length (puStack_584)
[filename_length bytes] texture_filename (acStack_50c)
texture = Graphics_LoadTexture(graphics, filename, 1)
puVar5[0x1D] = texture
ENDIF
Store submesh in meshworld+0x894 list
END FOR
Section 2: Animation Data
[4 bytes] animation_count (iStack_57C)
FOR each animation (i = 0..animation_count-1):
[4 bytes] animation_data_size (uStack_598)
[animation_data_size bytes] raw animation buffer
[4 bytes] keyframe_count (iStack_59C)
FOR each keyframe (j = 0..keyframe_count-1):
[4 bytes] keyframe_time (fStack_5A4)
[... keyframe data ...] (processed by FUN_004685E0 / FUN_00468510)
END FOR
FUN_00469090(animation, 0) // Finalize animation
Store in meshworld+0xCAC list
END FOR
Section 3: Objects (Transforms)
[4 bytes] object_count (iStack_574)
FOR each object (i = 0..object_count-1):
[4 bytes] object_type (iStack_550, checked for 0)
IF object_type == 0:
piVar8[0x34] = 3 // Set render mode
[4 bytes] position_x (iStack_5A0)
[4 bytes] position_y (uStack_58C)
[4 bytes] position_z (uStack_580)
vtable[4](x, y, z) // SetPosition
[4 bytes] rotation_param1
[4 bytes] rotation_param2 (uStack_598)
[4 bytes] rotation_param3 (uStack_58C)
vtable[8](param1, param2, param3) // SetRotation
[4 bytes] scale_x (puStack_588)
[4 bytes] scale_y (uStack_568)
[4 bytes] scale_z (uStack_56C)
Matrix_Scale4x4(scale_x, scale_y, scale_z, 1.0)
// Apply matrix transforms
piVar8[0x25..0x28] = transform matrix row 0
piVar8[0x2F..0x32] = scale matrix (0.8, 0.8, 0.8, 1.0) default
piVar8[0x2A..0x2D] = zero matrix
Store in meshworld+0x478 list
ENDIF
uStack_598++
END FOR
Section 4: Footer Data
[4 bytes] meshworld+0x454 value
[4 bytes] meshworld+0x458 value
[4 bytes] meshworld+0x45C value
[4 bytes] meshworld+0x468 value
[4 bytes] meshworld+0x46C value
[4 bytes] meshworld+0x470 value
[4 bytes] index_count (meshworld+0x438)
meshworld+0x43C = 0
[index_count * 32 bytes] index data (read as single block, stride=0x20)
meshworld+0x10F = this // Back-pointer
meshworld+0x131 = 1 // Initialized flag
// Timer init
timer = Timer_Init(0x44 bytes)
meshworld+0x10D = timer
// Custom vtable callback
vtable[0x34](filehandle, meshworld)
__close(filehandle)
Vertex Buffer Layout (Inferred from Data)
Based on binary analysis of Sphere.MESH:
- Position: 3 floats (X, Y, Z)
- Normal: 3 floats (NX, NY, NZ)
- TexCoord: 2 floats (U, V)
- Total: 8 floats = 32 bytes per vertex
Sphere.MESH vertex 0: pos=(25.0, 0.0, 0.0) norm=(0.999, -0.01, 0.054) uv=(0.75, 0.5)
This confirms a sphere of radius 25 units with UV wrapping.
Submesh Entry Structure (0x7C = 124 bytes)
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x00 | 4 | void* | Vertex buffer pointer |
| +0x04 | 4 | float | Field 1 (position X?) |
| +0x08 | 4 | float | Field 2 (position Y?) |
| +0x0C | 4 | float | Emissive R |
| +0x10 | 4 | float | Ambient R |
| +0x14 | 4 | float | Ambient G |
| +0x18 | 4 | float | Ambient B |
| +0x1C | 4 | float | Specular Power |
| +0x20 | 4 | float | Field (init 1.0) |
| +0x24 | 4 | float | Field (init 1.0) |
| +0x28 | 4 | float | Field (init 1.0) |
| +0x2C | 4 | float | Diffuse A |
| +0x30 | 4 | float | Diffuse R |
| +0x34 | 4 | float | Diffuse G |
| +0x38 | 4 | float | Diffuse B |
| +0x3C | 4 | float | Emissive G |
| +0x40 | 4 | float | Emissive B |
| +0x44 | 4 | float | Emissive A |
| +0x48 | 4 | float | Specular R |
| +0x4C | 4 | float | Specular G |
| +0x50 | 4 | float | Specular B |
| +0x54 | 4 | float | Specular A |
| +0x58 | 4 | float | Unknown R |
| +0x5C | 4 | float | Unknown G |
| +0x60 | 4 | float | Unknown B |
| +0x64 | 4 | float | Unknown A |
| +0x68 | 4 | float | Specular Power |
| +0x6C | 1 | bool | Has texture |
| +0x70 | 4 | void* | Texture pointer |
| +0x74 | 1 | bool | Has custom color |
Known Mesh Files
| File | Size | Submeshes | Vertices | Name | Texture |
|---|---|---|---|---|---|
| Sphere.MESH | 10181 | 6 | 59 | Sphere01 | HamsterBall.png |
| 8Ball.MESH | 7675 | 44 | 42 | Sphere01 | 8Ball.png |
| Sawblade.MESH | 6723 | 1 | 60 | Box01 | sawblade.png |
| Fanblades.MESH | 3864 | - | - | REF:FAN | - |
| Hamster-Waiting.MESH | 18492 | - | - | Hamster | - |
Reimplementation Notes (OpenGL/Assimp)
Loading Strategy
struct MeshSubmesh {
std::vector<float> vertex_buffer; // pos(3)+normal(3)+uv(2) per vertex
float position[3];
struct { float r, g, b, a; } diffuse, ambient, emissive, specular;
float specular_power;
bool has_texture;
Texture* texture;
};
struct MeshObject {
float position[3];
float rotation[3];
float scale[3];
float transform_matrix[4][4];
};
struct MeshFile {
std::vector<MeshSubmesh> submeshes;
std::vector<Animation> animations;
std::vector<MeshObject> objects;
static MeshFile Load(const std::string& filename);
};
Conversion Roadmap
- Write a
mesh2gltfconverter tool (Python/C++) - Parse .MESH binary → extract submeshes + vertex data
- Generate GLTF2 meshes with materials
- For animations: export as glTF2 animation channels
- Load in engine via Assimp or tinygltf
🔗 Related Documents
Mesh Object Gallery
Hamsterball Mesh Object Gallery
| # | Name | Vertices | Faces | Texture | Preview |
|---|---|---|---|---|---|
| 1 | 8Ball | 42 | 0 | 8Ball.png | ![]() |
| 2 | Chomper | 6 | 0 | ![]() |
|
| 3 | Eye | 32 | 0 | PurpleEye.png | ![]() |
| 4 | FunBall | 42 | 0 | FunBall.png | ![]() |
| 5 | GlassBonus | 26 | 0 | tenbonus.png | ![]() |
| 6 | Hamster-Trot1 | 122 | 0 | Hamster.jpg | ![]() |
| 7 | Hamster-Trot2 | 112 | 0 | Hamster.jpg | ![]() |
| 8 | Hamster-Trot3 | 117 | 0 | Hamster.jpg | ![]() |
| 9 | Hamster-Waiting | 122 | 0 | Hamster.jpg | ![]() |
| 10 | Hamster | 119 | 0 | Hamster.jpg | ![]() |
| 11 | Mouse | 243 | 0 | Hamm.png | ![]() |
| 12 | RBGlare | 18 | 0 | Glare.png | ![]() |
| 13 | Sawblade | 60 | 0 | sawblade.png | ![]() |
| 14 | Sphere+Tar | 19 | 0 | TarSplotch.png | ![]() |
| 15 | Sphere | 59 | 0 | HamsterBall.png | ![]() |
| 16 | SphereBreak1 | 38 | 0 | HamsterBall.png | ![]() |
| 17 | SphereBreak2 | 26 | 0 | HamsterBall.png | ![]() |
| 18 | TarBubble | 19 | 0 | TarBlot.png | ![]() |
🔗 Related Documents
MESHWORLD Binary Format
MESHWORLD Binary Format — Definitive Specification
Source: Official Raptisoft 3DS Max exporter (MeshWorldExport.cpp by John Raptis)
Reference: reference/raptisoft-exporter/MeshWorldExport/
Vertex Structure (32 bytes)
typedef struct Vertex {
float mX, mY, mZ; // Position (12 bytes)
float mNormalX, mNormalY, mNormalZ; // Normal (12 bytes)
float mTextureU, mTextureV; // UV coords (8 bytes)
} Vertex; // Total: 32 bytes
Binary File Layout
SECTION 1: Ref Points (game objects like START, FLAG, N:GOAL, etc.)
[int] point_count
FOR each point:
[int,len][string] name (length-prefixed, NUL-terminated)
[float] position.x // NOTE: x,z,y order (Max→D3D swap)
[float] position.z
[float] position.y
[float] rotation.x
[float] rotation.z
[float] rotation.y
[int] has_material // 0=no material, 1=has material
IF has_material:
[float×4] Ambient (r,g,b,a)
[float×4] Diffuse (r,g,b,a)
[float×4] Specular (r,g,b,a)
[float×4] Emissive (r,g,b,a)
[float] Power (shininess)
[int] has_reflection
[int] has_texture (0 or 1)
IF has_texture:
[int,len][string] texture_filename
SECTION 2: Splines (BallPath objects)
[int] spline_count
FOR each spline:
[int,len][string] name
[int] point_count
FOR each point:
[float] x // NOTE: x,z,y order
[float] z
[float] y
SECTION 3: Lights
[int] light_count
FOR each light:
[int] type (0=DISTANTLIGHT)
IF DISTANTLIGHT:
[float] position.x,z,y
[float] lookat.x,z,y
[float] color.r,g,b
SECTION 4: Background/Ambient Colors
[float×3] background_color (r,g,b)
[float×3] ambient_color (r,g,b)
SECTION 5: Global Vertex Buffer
[int] vertex_count
[Vertex×vertex_count] vertex_list // 32 bytes each
SECTION 6: Octree Mesh Dump (recursive)
[Cube] bounding_box // 6 floats: corner1(x,y,z) + corner2(x,y,z)
IF subdivided:
[int] submesh_count // >0 means has children
FOR each submesh:
[recursive SECTION 6]
ELSE (leaf node):
[int] 0 // 0 = no submeshes, leaf flag
[int] geom_count // Number of geometry objects
FOR each geom:
[int,len][string] name // "" unless N: or E: prefix or NOCOLLIDE
[float×4] Ambient (r,g,b,a)
[float×4] Diffuse (r,g,b,a)
[float×4] Specular (r,g,b,a)
[float×4] Emissive (r,g,b,a)
[float] Power
[int] has_reflection
[int] has_texture (0 or 1)
IF has_texture:
[int,len][string] texture_filename
[int] strip_count
FOR each strip:
[int] triangle_count // # triangles in this strip
[int] vertex_ref_offset // mSecondaryVertexListReference
// (offset into global vertex buffer)
Key Insights from Exporter Source
- X/Z/Y swap: All positions written as x,z,y (3DS Max Z-up → D3D Y-up)
- Octree structure: Mesh is recursively subdivided into cubes for visibility culling
- Global vertex buffer: All vertices in a single buffer, strips reference into it
- Triangle strips: Not raw indices — each strip stores
triangle_count+vertex_ref_offset
into the global vertex buffer (WorldOptimize adjusts offsets) - Geometry names: Only written if they have
N:orE:prefix or containNOCOLLIDE - Materials stored per-geom: Ambient/Diffuse/Specular/Emissive + Power + texture + reflection
- 16384 triangle cap: From Carsillas' notes — game crashes if level exceeds 2^14 triangles
String Format
[int] length (including NUL terminator)
[length bytes] string data (NUL-terminated, may be empty string with length=1)
Cube Structure (24 bytes)
[float] corner1.x
[float] corner1.y
[float] corner1.z
[float] corner2.x
[float] corner2.y
[float] corner2.z
Reading Order for Level Rendering
- Skip ref points (Section 1) — already parsed by our existing code
- Skip splines (Section 2)
- Skip lights (Section 3)
- Read background + ambient colors (Section 4)
- Read global vertex buffer (Section 5) — THIS is the geometry to render
- Walk octree (Section 6) — each leaf has geoms with materials + strip references into vertex buffer
Original Code References
- Format writer:
MeshWorldExport.cpp:DoExport()line 186 - Vertex struct:
Math.hline 18-30 - Octree dump:
WorldMesh.cpp:Dump()line 259 - String write:
MeshWorldExport.cpp:WriteString()line 147 (length-prefixed)
🔗 Related Documents
MESHWORLD File Format (CONFIRM
MESHWORLD File Format (CONFIRMED)
Overview
Binary file format used for Hamsterball levels. Contains 3D geometry data
followed by object definitions with positions, transforms, and materials.
File Structure
[Geometry Section]
- Binary mesh data (vertices, faces, normals, UVs)
- Bounding box data
- May be empty (count=0 in Arena-SpawnPlatform)
[Object Section]
- uint32: total object count
- Sequential object definitions (variable length per type)
Geometry Section
Starts at offset 0. The section contains:
- First uint32 may be 0 (Arena-SpawnPlatform) or a vertex count
- Followed by floats for bounding box and vertex data
- Variable-length mesh geometry data
Object Section Format
Each object definition has this structure:
[uint32: type_string_length]
[type_string: variable bytes, null-padded to length]
[object data: variable size depending on type]
- Position: 3 floats (x, y, z)
- Rotation/scale: 4 values (Quaternion or Euler+scale)
- Material properties (for complex types)
- Texture reference (for complex types):
[uint32: texture_string_length]
[texture_string: null-terminated filename]
- Face/vertex indices (for complex types)
Object Type Sizes (CONFIRMED from binary analysis)
Simple Objects (28 bytes after type string)
START positions (START1-1, START2-1, START2-2)
[float: x_position]
[float: y_position]
[float: z_position]
[uint32: 0] # rotation x (0)
[uint32: 0] # rotation y (0)
[float: 0x80000000] # rotation z (-0.0 or direction flag)
[uint32: 0] # padding/flags
SAFESPOT (28 bytes after type string)
Same structure as START.
Complex Objects (variable size)
FLAG (FLAG02, FLAG04, FLAG06, FLAG07)
- 104 bytes after type string
- Then texture reference string (e.g., "BlackCheckerFlag.png")
- Then 0 bytes before next object
- Contains: position(12) + rotation(16) + scale/matrix(32) + color(16) + size_param(4) + flags(8) + face indices
PLATFORM / N:SINKPLATFORM
- 76 bytes after type string (transform + color + size)
- Then texture reference string
- Then mesh face index data (pairs of index + value)
- Size parameter: 35.0 for standard platforms
E:NODIZZY
- Contains embedded XML property for timer
- Followed by transform matrix (16 floats = 4x4 identity-like)
- Then position data (3 floats for center, repeated as 4x3 matrix)
- Closing: uint32(0), uint32(0), uint32(0), uint32(1), uint32(2), uint32(global_count)
Material/Transform Data (for complex objects)
Offset Size Description
0x00 32 Rotation/scale matrix (8 floats, identity = all 1.0)
0x20 12 Diffuse color RGB (3 floats, e.g., 0.45, 0.45, 0.45)
0x2C 4 Alpha/opacity (float, e.g., 0.5)
0x30 12 Ambient/specular (3 floats, often 0, 0, 0)
0x3C 4 Ambient alpha (float, often 1.0)
0x40 4 Platform size parameter (float, e.g., 35.0)
0x44 4 Flag 1 (uint32, often 1)
0x48 4 Flag 2 (uint32, often 1)
[then texture string reference]
[then face index data]
File Trailer (CONFIRMED from Arena-SpawnPlatform)
After all objects:
00 00 00 00 # position zeros
00 00 00 00
00 00 00 00
01 00 00 00 # uint32 = 1
02 00 00 00 # uint32 = 2 (same as object count)
1E 00 00 00 # uint32 = 30 (total object/geometry count?)
Arena-SpawnPlatform Example Parse
- File size: 1,476 bytes
- Geometry: mostly zeros (minimal geometry)
- Object count: 2 (N:SINKPLATFORM + E:NODIZZY)
- N:SINKPLATFORM: position at identity, diffuse color 0.45,0.45,0.45, alpha 0.5, size 35.0, texture "PinkChecker.bmp"
- E:NODIZZY: identity transform, scale 0.5
Level1 Example Parse
- Object count: 30 objects
- Types including: START1-1, FLAG04, FLAG07, SAFESPOT (x8), START2-1, START2-2, FLAG02, FLAG06, SAFESPOT (x8+), PLATFORM, CAMERALOOKAT, etc.
🔗 Related Documents
MESHWORLD File Format & Object
MESHWORLD File Format & Object System — Definitive Reference
Verified against: Binary .MESHWORLD files, official Raptisoft exporter source (MeshWorldExport.cpp, WorldMesh.cpp, NodeEnumerator.cpp), and Ghidra decompilations of Hamsterball.exe.
1. Two File Formats
Hamsterball uses two mesh file formats:
1.1 .MESHWORLD (Binary) — Level/world files
- Written by the official Raptisoft 3DS Max exporter plugin (
MeshWorldExport.cpp) - Loaded by the game engine via binary file I/O (
__readcalls) - Contains: ref points, splines, lights, colors, vertex buffer, octree mesh
- File extension:
.MESHWORLD(e.g.,Level1.MESHWORLD,Arena-WarmUp.MESHWORLD) - 37 level files + 15 arena files + sub-level files (bridges, etc.)
1.2 .ASE (Text) — Individual mesh objects
- 3DS Max ASCII Scene Export format
- Parsed by
MeshWorld_Parse(0x470930) — line-by-line text parser - Keywords:
*MATERIAL,*GEOMOBJECT,*MESH_VERTEX,*MESH_FACE, etc. - Used for loading individual mesh objects (not entire levels)
- Material struct: 0x50 bytes per material (ambient/diffuse/specular/shine/texture)
2. Binary .MESHWORLD Format (6 Sections)
Section 1: Ref Points (Game Objects)
[int32] point_count
FOR each point:
[int32,len][string] name (length-prefixed, NUL-terminated)
[float] position.x (NOTE: x,z,y order — Max Z-up → engine Y-up)
[float] position.z
[float] position.y
[float] rotation.x (yaw)
[float] rotation.z
[float] rotation.y (roll)
[int32] has_material (0=no material, 1=has material)
IF has_material:
[float×4] Ambient (r,g,b,a)
[float×4] Diffuse (r,g,b,a)
[float×4] Specular (r,g,b,a)
[float×4] Emissive (r,g,b,a)
[float] Power (shininess)
[int32] has_reflection
[int32] has_texture (0 or 1)
IF has_texture:
[int32,len][string] texture_filename
Ref points are the game logic objects — spawn points, triggers, factory objects.
Section 2: Splines (Path Objects)
[int32] spline_count
FOR each spline:
[int32,len][string] name
[int32] point_count
FOR each point:
[float] x (x,z,y order)
[float] z
[float] y
Splines are used for BlockDawg patrol paths (DAWGPATH1, DAWGPATH2, DAWGPATH3).
Section 3: Lights
[int32] light_count
FOR each light:
[int32] type (0 = DISTANTLIGHT)
IF type == 0:
[float×3] position (x,z,y)
[float×3] lookat (x,z,y)
[float×3] color (r,g,b)
Section 4: Background & Ambient Colors
[float×3] background_color (r,g,b)
[float×3] ambient_color (r,g,b)
Section 5: Global Vertex Buffer
[int32] vertex_count
[Vertex×N] vertex_list (32 bytes per vertex)
Vertex struct (32 bytes):
struct Vertex {
float mX, mY, mZ; // Position (12 bytes)
float mNormalX, mNormalY, mNormalZ; // Normal (12 bytes)
float mTextureU, mTextureV; // UV coords (8 bytes)
};
Section 6: Octree Mesh Dump (Recursive)
[Cube] bounding_box // 6 floats: corner1(x,y,z) + corner2(x,y,z)
[int32] submesh_count
IF submesh_count > 0:
FOR each submesh:
[recursive Section 6] // child cube
ELSE (leaf node):
[int32] geom_count // number of geometry objects
FOR each geom:
[int32,len][string] name // "" unless name[1]==':' OR contains "NOCOLLIDE"
[float×4] Ambient
[float×4] Diffuse
[float×4] Specular
[float×4] Emissive
[float] Power
[int32] has_reflection
[int32] has_texture
IF has_texture:
[int32,len][string] texture_filename
[int32] strip_count
FOR each strip:
[int32] triangle_count
[int32] vertex_ref_offset // offset into global vertex buffer
Key exporter rule (WorldMesh.cpp:291): A geom name is only written if:
name[1] == ':'(anyX:prefix), OR- The name contains
"NOCOLLIDE"
Otherwise, an empty string""is written (unnamed geometry).
3. Object Name Prefix System
3.1 Ref Point Names (Section 1) — Game Logic Objects
Parsed into a hash table during scene construction. Looked up by name during Scene_SpawnBallsAndObjects (0x41C5B0) and CreateLevelObjects (0x4121D0).
| Category | Examples | Handler |
|---|---|---|
| Spawn points | START1-1, START2-1, START2-2, START-DEBUG |
Scene_SpawnBallsAndObjects — ball placement |
| Checkpoints | SAFESPOT, SAFEPOS, SAFESPOT(A) |
Appended to scene->safespots list |
| Race flags | FLAG02–FLAG18 |
Scene_CreateFlags — checkpoint flags |
| Enemy balls | BADBALL <CHASE>100</CHASE><HOME>400</HOME> |
CreateBadBall — XML-parameterized AI ball |
| Factory objects | BRIDGE, TIPPER, BONK, BBRIDGE1, BBRIDGE2, POPCYLINDER, BLOCKDAWG1/2, CATAPULT, GLUEBIE |
CreateLevelObjects (0x4121D0) — prefix-dispatched |
| Arena objects | BONK, TIP, SAWBLADE, BRIDGE, JUDGE, BELL |
CreateExpertLevelObjects (0x40E250) — arena sub-factory |
| Bumpers | BUMPER1–BUMPER8 |
CreateBumper (0x40FA20) — loads levels\level8 |
| Camera | CAMERALOOKAT |
Camera target point (present in all 15 race levels) |
| Secrets | SECRET, SECRETUNLOCK, N:SECRET |
CreateSecretObjects — hidden collectibles |
| Signs | SIGN-TARPIT |
Scene_CreateSigns |
| Splines | DAWGPATH1, DAWGPATH2, DAWGPATH3 |
Level_FindObjectByName → BlockDawg patrol path |
| Misc refs | FAN, FAN(UP), FAN(SUPER)(UP), GEAR01–GEAR24, BIGGEAR01–BIGGEAR14, TURRET, DRAWBRIDGE, etc. |
Various level-specific handlers |
3.2 Octree Geom Names (Section 6) — Collision & Render Objects
Parsed by the binary MESHWORLD loader (function at ~0x461680) into MeshBuffer objects. The loader checks geom name prefixes and sets render classification flags on the MeshBuffer struct:
// Binary MESHWORLD loader (~0x461680) — checks ALL prefixes:
if (name[1] == ':') obj->has_named_prefix = 1; // +0x85C
if (strnicmp(name, "O:", 2) == 0) obj->is_translucent = 1; // +0x862
if (strnicmp(name, "T:", 2) == 0) obj->is_decal = 1; // +0x85F
if (strnicmp(name, "N:GLASS", 7)==0)obj->is_alpha_test = 1; // +0x860
if (strnicmp(name, "E:", 2) == 0) obj->no_render = 1; // +0x863
if (strstr(name, "(NOSHADOW)")) obj->no_shadow = 1; // +0x85E
// Text/.COL loader (Level_LoadCollision 0x465260) — checks N: and E: only:
if (strnicmp(name, "N:", 2) == 0) buf->interactive = 1; // +0x85D
if (strnicmp(name, "E:", 2) == 0) { buf->interactive = 1; buf->no_render = 1; } // +0x85D, +0x863
These flags control how Scene_RenderAllObjects (0x45E0E0) classifies each geom into render passes:
- Opaque pass (default): AlphaBlend OFF, AlphaTest OFF
- Translucent pass (
+0x862): AlphaBlend ON — objects behind transparent parts are still visible. This is why the ball renders correctly behindO:tubes. - Alpha test pass (
+0x860): Sorted into translucent bucket with per-pixel alpha cutoff - Decal pass (
+0x85F): Stencil-based, with depth bias - Skip (
+0x863): Not rendered at all (invisible collision zones) - No shadow (
+0x85E): Excluded from shadow rendering pass
Verified by user testing: Changing O: to A: in a level file causes the ball to stop rendering behind tubes, confirming O: directly sets the translucent render flag (+0x862).
| Prefix | Count | Engine Flag | Offset | Render Pass | Behavior |
|---|---|---|---|---|---|
N: |
278 | interactive | +0x85D | Opaque | Named collision, triggers event handler on hit (set by text loader) |
E: |
581 | no_render | +0x863 | Skip (invisible) | Invisible collision zone, triggers event on hit |
O: |
60 | is_translucent | +0x862 | Translucent (alpha blend) | See-through render — ball visible behind transparent parts (tubes, saws) |
T: |
175 | is_decal | +0x85F | Decal (stencil + depth bias) | Texture overlays: arrows, warnings, bullseyes |
N:GLASS |
— | is_alpha_test | +0x860 | Alpha test (sorted into translucent) | Alpha-tested transparency (glass platforms) |
(NOSHADOW) |
— | no_shadow | +0x85E | Excluded from shadow pass | Designer tag in name, checked via strstr |
S: |
831 | (none specific) | — | Opaque (standard) | Standard collision + standard render. (NOSHADOW) suffix in name sets +0x85E. |
| (none) | — | (none) | — | Opaque (standard) | Standard level geometry (walls, floors) |
3.3 Complete N: Object Catalog (44 unique types)
| Name | Triangles | Texture | Files | Behavior |
|---|---|---|---|---|
N:BOUNCE(NOSHADOW) |
38 | — | 1 | Bouncy surface (no shadow) |
N:BRIDGE |
244 | — | 1 | Bridge segment (movable) |
N:BUMP |
100 | — | 1 | Bump/perturbation surface |
N:BUMPER1–N:BUMPER8 |
24-156 | — | 2-4 | Arena bumpers |
N:DROPIN |
2 | — | 1 | Pipe drop-in trigger |
N:EXTRATIME |
8 | FiveBonus.png | 1 | Extra time collectible |
N:GLASS |
32 | — | 2 | Glass platform (breakable?) |
N:GOAL |
8 | GreyOutlineChecker.png | 15 | Race finish line |
N:JUMPFIRST / N:JUMPSECOND |
2 | — | 1 | Jump pad (sequential) |
N:LOOPER(NOSHADOW) |
54 | RedChecker.bmp | 1 | Loop-the-loop section |
N:MOUSETRAP |
2-6 | TrapTop/TrapSpring/TrapMetal.jpg | 1 | Mouse trap obstacle |
N:NEONPLATFORM |
18 | — | 1 | Neon-lit platform |
N:NOCONTROL |
2 | GreenChecker.bmp | 1 | Disable ball input zone |
N:ONGEAR(NOSHADOW) |
16 | — | 1 | Gear surface (rides on gear) |
N:ONPENDULUM |
26 | RedChecker.bmp | 1 | Pendulum surface |
N:ONROTATOR |
8 | RedChecker.bmp | 1 | Rotator surface |
N:SAWTEETH |
4 | — | 1 | Saw teeth hazard |
N:SECRET / N:SECRET(NOSHADOW) |
6-12 | GreyOutlineChecker.png | 1 | Hidden collectible |
N:SINKPLATFORM |
10 | PinkChecker.bmp | 1 | Sinking platform |
N:SPEEDCYLINDER |
17 | SpeedCylinder.png | 1 | Speed boost cylinder |
N:SPINNY |
62 | — | 1 | Spinning platform |
N:SQUAREWOBBLY / (NOSHADOW) |
2-336 | BrightGreenChecker.bmp | 1-8 | Wobbly square platform |
N:SWIRL |
158 | — | 1 | Swirl/vortex section |
N:TARPIT |
8 | — | 2 | Tar pit (slows ball) |
N:TENBONUS1 / N:TENBONUS2 |
2 | — | 1 | +10 bonus collectible |
N:TRAPDOOR |
2 | YelllowChecker.png | 2 | Trapdoor (opens on trigger) |
N:UNLOCKSECRET |
2 | LockTile.png | 12 | Secret unlock trigger |
N:WATERWHEEL(NOSHADOW) |
144 | — | 1 | Water wheel obstacle |
N:WAVY |
168 | BrightGreenChecker.bmp | 1 | Wavy platform |
N:WHEELEMBED |
6 | — | 1 | Embedded wheel section |
3.4 Complete E: Event Catalog (86 unique types)
| Name | Behavior |
|---|---|
E:LIMIT |
Arena boundary / fall-off zone (323 instances — most common) |
E:DROPIN |
Pipe drop-in sound + score (28 instances, 12 files) |
E:POPOUT |
Pipe pop-out sound + score (30 instances, 11 files) |
E:PIPEBONK |
Pipe collision sound (random of 3) (22 instances, 4 files) |
E:SAFESWITCH(A)–(H) |
Track which checkpoint/safe zone was hit |
E:NODIZZY<TIME>N</TIME> |
Anti-dizzy zone with duration (50-600) |
E:JUMP |
Jump pad: upward velocity + sound + score |
E:CATAPULTBOTTOM |
Launch catapult |
E:OPENSESAME |
Open first trapdoor |
E:BITE |
Damage: 25.0 |
E:MACETRIGGER |
Activate all maces |
E:CALLHAMMER |
Spawn hammer chase (arena MP) |
E:HAMMERCHASE |
Start hammer chase sequence |
E:ALERTSAW1/2 |
Pre-activate saw blade (warning) |
E:ACTIVATESAW1/2 |
Full activate saw blade |
E:ALERTJUDGES |
Reset all judge objects |
E:SCORE1/5/7/9 |
Set score display time |
E:BELL |
Extra time +5s + "EXTRA TIME:" popup |
E:GRAVITY<TYPE>X/Z/NORMAL</TYPE> |
Change gravity direction |
E:TRAJECTORY<X>..</X><Y>..</Y><Z>..</Z> |
Set ball trajectory |
E:ACTION<ONCE>TRUE</ONCE><SCORE>N</SCORE> |
Score action (one-time or repeatable) |
E:LAUNCH |
Launch ball |
E:SHRINK |
Shrink ball |
E:SWALLOW |
Swallow/absorb ball |
E:TRAPPOP |
Trapdoor pop |
E:VACPOPOUT |
Vacuum pop-out |
E:ZOOP |
Speed boost |
E:HEATON / E:HEATOFF |
Heat effect on/off |
E:LIGHTSON / E:LIGHTSOFF |
Lights on/off |
E:HELPINERTIA / E:UNHELPINERTIA |
Assist inertia on/off |
E:PEGS / E:NOPEGS |
Toggle pegs |
E:GROWSOUND |
Grow sound effect |
E:BRANCH(A) / E:BRANCH(B) |
Branch path selection |
E:DROPLIFT |
Drop lift |
E:LIMITPIPE1/2 |
Pipe limit zones |
E:LIMITX / E:LIMITZ |
Axis-specific limit zones |
4. How Objects Interact with the EXE
4.1 Loading Pipeline
1. App selects level → MeshWorld_ctor(gfx) creates MeshWorld struct (0x488 bytes)
2. Level_LoadCollision (0x465260) reads binary .MESHWORLD:
a. Reads header (24 bytes into MeshWorld+0x45C)
b. Reads sublevel_count
c. If single-level: reads objects with named collision faces
d. If multi-level: creates sub-levels recursively
3. Each named collision object → CreateMeshBuffer (0x874 bytes)
- Name stored at +0x864 (char*)
- N: prefix → +0x85D = 1 (interactive)
- E: prefix → +0x85D = 1, +0x863 = 1 (interactive + no_render)
4. Scene construction:
a. Scene_SpawnBallsAndObjects (0x41C5B0) — reads ref points by name
b. CreateLevelObjects (0x4121D0) — factory dispatches by ref point name prefix
c. CreateExpertLevelObjects (0x40E250) — arena object sub-factory
d. CreateBumper (0x40FA20) — bumper loader
e. CreateMouseTrap (0x40BF50) — mouse trap spawner
5. Per-frame: Ball_AdvancePositionOrCollision checks CollisionLevel
→ Mesh_FindClosestCollision (0x465D90) raycasts against named faces
→ On hit: TowerCollisionEvents or ExpertCollisionEvents dispatches by name
4.2 Collision Dispatch Chain
Ball physics update
→ Mesh_FindClosestCollision (0x465D90) — raycast against CollisionLevel
→ If hit named collision object:
→ TowerCollisionEvents (0x40DCD0) [Tower board]
OR ExpertCollisionEvents (0x40E6A0) [arenas]
→ __stricmp / __strnicmp on object name (at collider+0x864)
→ Dispatch to specific handler (Catapult_Launch, Trapdoor_Open, etc.)
→ Always ends with: DispatchCollisionEvents (0x40C5D0) — base event handler
4.3 DispatchCollisionEvents — The Base Event Handler (0x40C5D0)
Handles ALL events not consumed by level/arena-specific handlers. Uses __strnicmp/__stricmp on the event name string:
- N:SECRET →
Rotator_MarkTriggered - N:UNLOCKSECRET →
CheckArenaUnlock - E:NODIZZY<TIME>N</TIME> → Parse XML tags,
Ball_DizzyImmunity - E:SAFESWITCH(data) → Copy parenthesized data to ball state
- E:LIMIT → Arena fall-off: increment other players' completion counts
- E:BREAK → Call
ball->vtable[0x20]()(bounce) - E:JUMP → Sound + upward velocity + score
- E:ACTION<ONCE>..<SCORE>..</SCORE> → Parse XML, award points
- E:TRAJECTORY(X,Y,Z) → Parse XML tags, set ball trajectory
- N:NOCONTROL → Disable input for 10 frames
- N:WATER → Set water flag + 10-frame counter
- N:TARPIT → Slow ball + tar sound
- E:DROPIN → Drop-in sound + score (distance-gated)
- E:PIPEBONK → Random pipe bonk sound (1 of 3)
- E:POPOUT → Pop-out sound + score
- N:GOAL → Finish race! Play music, store time
- N:MOUSETRAP → Deflect ball, track rotator collision
4.4 Factory Dispatch (CreateLevelObjects 0x4121D0)
Ref point names are matched via __strnicmp to instantiate game objects:
| Prefix | Match Length | Constructor | Size | Scene Offset |
|---|---|---|---|---|
BRIDGE |
6 | (configures existing mesh) | — | +0x436C |
TIPPER |
6 | Tipper_ctor + TipperVisual_ctor |
0x1104 + 0x10D0 | +0x2578 |
BONK |
4 | Bonk_ctor |
0x1200 | +0x2578, +0x540C |
BBRIDGE1 |
8 | BreakBridge_ctor |
0x1100 | +0x5418 |
BBRIDGE2 |
8 | BreakBridge_ctor |
0x1100 | +0x541C |
POPCYLINDER |
11 | PopCylinder_ctor |
0x10E8 | +0x5428 |
BLOCKDAWG1 |
10 | Blockdawg_ctor |
0x1154 | +0x2578 |
BLOCKDAWG2 |
10 | Blockdawg_ctor |
0x1154 | +0x2578 |
CATAPULT |
8 | Catapult_ctor |
0x1108 | +0x584C |
GLUEBIE |
7 | Gluebie_ctor |
0x110C | +0x6080 |
4.5 Arena Sub-Factory (CreateExpertLevelObjects 0x40E250)
Despite the name, this handles 6 arena object types:
| Prefix | Match Length | Constructor | Size | Scene Offset |
|---|---|---|---|---|
BONK |
4 | Bonk_ctor |
0x1200 | +0x436C |
TIP |
3 | TowerLevel_Ctor |
0x1188 | +0x2578 |
SAWBLADE |
8 | Sawblade_Level_Ctor |
0x111C | +0x4370/4374 |
BRIDGE |
6 | Spinner_Level_ctor |
0x10FC | +0x4380/4798 |
JUDGE |
5 | Gear_Level_ctor |
0x1100 | +0x4BBC |
BELL |
4 | Tipper_Level_Ctor |
0x10E8 | +0x4FD4 |
Modifiers via strstr: SLOW, SUPER, UP (TowerLevel), 1/2 (Sawblade/Spinner), NEG (Spinner direction).
5. BADBALL — Parameterized Enemy Ball
BADBALL ref points use XML-style tags for parameters:
BADBALL <CHASE>100</CHASE><HOME>400</HOME><SIZE>18</SIZE><SPINDISTANCE>25</SPINDISTANCE>
| Tag | Meaning | Example |
|---|---|---|
<CHASE> |
Chase distance (when to start pursuing) | 100-300 |
<HOME> |
Home distance (return-to-home radius) | 100-1000 |
<SIZE> |
Ball radius | 18 |
<SPINDISTANCE> |
Spin activation distance | 25-45 |
6. Render Pipeline Integration
Objects are classified into render buckets by flags on SceneObject:
+0x863: has_bounding_sphere (skip if set)+0x862: is_translucent (alpha blend)+0x85F: is_decal (stencil-based)+0x860: is_alpha_test (sorted into translucent)+0x861: has_per_object_alpha
Scene_RenderAllObjects (0x45E0E0) — 3-pass render:
- Opaque pass: AlphaBlend OFF, AlphaTest OFF
- Translucent pass: AlphaBlend ON, AlphaTest OFF
- Decal pass: Stencil + depth bias
Render bucket classification is driven by name prefix flags set during MESHWORLD loading (see §3.2 above), NOT by material properties. The binary loader at ~0x461680 checks each prefix and sets the corresponding flag byte on the MeshBuffer struct before rendering.
| Flag | Offset | Set by | Render effect |
|---|---|---|---|
| has_named_prefix | +0x85C | name[1]==':' |
Name was preserved in file |
| no_shadow | +0x85E | (NOSHADOW) substring |
Excluded from shadow pass |
| is_decal | +0x85F | T: prefix |
Stencil + depth bias render pass |
| is_alpha_test | +0x860 | N:GLASS prefix |
Sorted into translucent with alpha cutoff |
| is_translucent | +0x862 | O: prefix |
Alpha blend ON — see-through rendering |
| no_render | +0x863 | E: prefix |
Skipped entirely (invisible) |
7. String Format Convention
All strings in .MESHWORLD files are length-prefixed:
[int32] length (including NUL terminator)
[length bytes] string data (NUL-terminated)
Empty string = length 1, single \x00 byte.
References
- Official exporter:
reference/raptisoft-exporter/MeshWorldExport/ - Binary loader:
Level_LoadCollisionat 0x465260 (Ghidra decompilation) - ASE parser:
MeshWorld_Parseat 0x470930 (Ghidra decompilation) - Collision dispatch:
TowerCollisionEventsat 0x40DCD0,ExpertCollisionEventsat 0x40E6A0 - Base event handler:
DispatchCollisionEventsat 0x40C5D0 - Object factory:
CreateLevelObjectsat 0x4121D0,CreateExpertLevelObjectsat 0x40E250
🔗 Related Documents
MESHWORLD Format
MESHWORLD Format — Deep Specification
Overview
The .MESHWORLD format is the core level/scene description format for Hamsterball. It contains:
- 3D mesh geometry (vertices, faces, normals, UVs)
- Object placement definitions with positions, rotations, and properties
- Collision geometry references
- Event trigger volumes
- Visual decoration and lighting data
Complete Object Type Taxonomy
Object types follow a PREFIX:NAME(MODIFIER) pattern:
Prefix System
| Prefix | Category | Description |
|---|---|---|
START |
Player Start | Spawn positions for 1P and 2P |
FLAG |
Checkpoint | Race progress checkpoints |
SAFESPOT |
Safety | Landing/recovery spot |
CAMERALOOKAT |
Camera | Camera target position |
CAMERALOCUS |
Camera | Camera focus point |
E: |
Event (enter) | Triggered when ball enters volume |
N: |
Named Object | Interactive game object with collision |
O: |
Object | Static/moving geometry objects |
S: |
Structural | Non-shadow decorative geometry |
T: |
Texture/Decal | Non-colliding visual elements |
W: |
World | World boundary |
Modifiers (appended in parentheses)
| Modifier | Effect |
|---|---|
(NOCOLLIDE) |
No collision with ball |
(NOSHADOW) |
Does not cast/receive shadows |
(WANTZ) |
Uses Z-buffer for depth test |
<TIME>NNN</TIME> |
XML-embedded parameter (duration in seconds) |
E: Events (Ball-Enter Triggers)
| Event | Description |
|---|---|
| E:ACTION | Generic action trigger |
| E:ACTIVATESAW1/2 | Activate sawblade 1 or 2 |
| E:ALERTJUDGES | Notify judge objects |
| E:ALERTSAW1/2 | Alert/activate saw 1 or 2 |
| E:BELL | Ring bell |
| E:BITE | Chomper bite |
| E:BRANCH(A)/(B) | Branch path A or B |
| E:CALLHAMMER | Summon hammer |
| E:CATAPULTBOTTOM | Catapult launch zone |
| E:DROPIN/01 | Drop ball into area |
| E:DROPLIFT | Drop lift platform |
| E:GRAVITY | Change gravity |
| E:GROWSOUND | Play grow sound |
| E:HAMMERCHASE | Start hammer chase |
| E:HEATOFF/HEATON | Heat hazard toggle |
| E:HELPINERTIA/UNHELPINERTIA | Assist/stop inertia |
| E:JUMP | Jump pad |
| E:LAUNCH | Launch ball |
| E:LIGHTSOFF/LIGHTSON | Neon lights toggle |
| E:LIMIT/LIMITX/LIMITZ/LIMITPIPE1/2 | Boundary limits |
| E:MACETRIGGER | Trigger mace swing |
| E:NODIZZY | Disable dizzy effect (with <TIME>) |
| E:NOPEGS | Remove pegs |
| E:OPENSESAME | Open door/gate |
| E:PEGS | Add pegs |
| E:PIPEBONK | Pipe collision (random sound from 3) |
| E:PIPERANDOM | Random pipe effect |
| E:POPOUT/01 | Pop ball out of area |
| E:SAFESWITCH(A-Z) | Safe zone switch (multiple variants) |
| E:SCORE1/5/7/9 | Score bonus (1/5/7/9 points) |
| E:SHRINK | Shrink ball |
| E:SWALLOW | Swallow/absorb ball |
| E:TRAJECTORY | Ball trajectory change |
| E:TRAPPOP | Trapdoor popup |
| E:VACPOPOUT | Vacuum popout |
| E:ZOOP | Quick movement effect |
N: Named Objects (Interactive)
| Object | Description |
|---|---|
| N:BOUNCE(NOSHADOW) | Bounce pad |
| N:BRIDGE | Destructible/movable bridge |
| N:BUMP | Bump obstacle |
| N:BUMPER1-8 | Bumper variants (1-8) |
| N:DROPIN | Drop-in zone |
| N:EXTRATIME | Time bonus pickup |
| N:GLASS | Glass/breakable platform |
| N:GOAL | Race goal finish line |
| N:JUMPFIRST/JUMPSECOND | Jump sequence triggers |
| N:MOUSETRAP | Mousetrap hazard |
| N:NEONPLATFORM | Neon-lighted platform |
| N:NOCONTROL | Disable player control |
| N:ONGEAR(NOSHADOW) | Ride on gear/rotator |
| N:ONPENDULUM | Ride on pendulum |
| N:ONROTATOR | Ride on rotating platform |
| N:SAWTEETH | Saw blade teeth |
| N:SECRET/SECRET(NOSHADOW) | Secret unlock spot |
| N:SINKPLATFORM | Sinking platform |
| N:SPEEDCYLINDER | Speed boost cylinder |
| N:SPINNY | Spinning obstacle |
| N:SQUAREWOBBLY | Square wobbling platform |
| N:SWIRL | Swirl/vortex |
| N:TARPIT | Tar pit (slow zone) |
| N:TENBONUS1/2 | 10-point bonus pickup |
| N:TRAPDOOR | Trapdoor (falls when stepped on) |
| N:UNLOCKSECRET | Unlock secret content |
| N:WATERWHEEL(NOSHADOW) | Water wheel mechanism |
| N:WAVY | Wavy water surface |
| N:WHEELEMBED | Embedded wheel/axle |
| N:LOOPER(NOSHADOW) | Loop-de-loop section |
O: Object Types (Static/Moving Geometry)
| Object | Description |
|---|---|
| O:EXITTOOB/01-06 | Tube exit points |
| O:HANDLE | Handle/grab point |
| O:SAW | Sawblade assembly |
| O:TOOB | Tube/tunnel section |
| O:TOOBY01-06 | Tube variants |
S: Structural Types (Non-Shadow Decorative)
| Object | Description |
|---|---|
| S:AXLE(NOSHADOW) | Axle/rotation pivot |
| S:BRICK/BRICKS(NOSHADOW) | Brick walls |
| S:BUGFIXPLANE(NOSHADOW) | Invisible collision fix plane |
| S:EnlargePipe(NOSHADOW) | Pipe widening section |
| S:PIPERIM(NOSHADOW) | Pipe rim geometry |
| S:PIPING(NOSHADOW) | Pipe sections |
| S:Pipes(NOSHADOW) | Multiple pipe geometry |
| S:RAILS(NOSHADOW) | Rail tracks |
| S:Rim(NOSHADOW) | Rim/border geometry |
| S:ShrinkPipe(NOSHADOW) | Pipe narrowing section |
| S:TUBE(NOSHADOW) | Tube geometry |
| S:WALLS(NOSHADOW) | Wall sections |
T: Texture/Decal Types (Visual Only, No Collision)
| Type | Description |
|---|---|
| T:ARROW/01(NOCOLLIDE) | Direction arrows on floor |
| T:ARROWCURVE(NOCOLLIDE) | Curved direction arrows |
| T:BULLSEYE(NOCOLLIDE)(WANTZ) | Target bullseye pattern |
| T:FLOORLIGHTS | Floor lighting patterns |
| T:GOALAREA(NOCOLLIDE) | Goal zone visual overlay |
| T:GOSPOT(NOCOLLIDE) | Go spot indicator |
| T:GoalImage/GoalOverlay(NOCOLLIDE) | Goal visual effects |
| T:JUMPARROW(NOCOLLIDE) | Jump pad arrow indicator |
| T:LIGHT/01/LIGHTS | Light sources |
| T:Lighties | Small light decorations |
| T:NEON/08-13 | Neon light strips |
| T:NEONARROW(NOCOLLIDE) | Neon direction arrows |
| T:NEONRING/01/02 | Neon ring lights |
| T:NEONSTRIP | Neon strip light |
| T:RAMPARROW(NOCOLLIDE) | Ramp direction arrow |
| T:SIDEARROW(NOCOLLIDE) | Side wall direction arrow |
| T:SPEEDARROW | Speed boost arrow |
| T:START/STARTER/STARTSPOT(NOCOLLIDE) | Start position indicators |
| T:STARTSTOP(NOCOLLIDE) | Start/stop markers |
| T:TUBETOP/01/02/03 | Tube top openings |
| T:TunnelArrow(NOCOLLIDE) | Tunnel direction arrow |
| T:WARN/WARNING/Warning(NOCOLLIDE) | Warning signs |
| T:WARNINGDECAL(NOCOLLIDE) | Warning decal texture |
| T:YELLOWARROW(NOCOLLIDE) | Yellow direction arrow |
| T:ZIGZAG(NOCOLLIDE) | Zigzag pattern |
Start Position Format
| Type | Description |
|---|---|
| START1-1 | Player 1 start |
| START2-1 | Player 2 start (2-player mode) |
| START2-2 | Player 2 alternate start |
Checkpoint System
| Type | Description |
|---|---|
| FLAG02 | Checkpoint 2 |
| FLAG04 | Checkpoint 4 |
| FLAG06 | Checkpoint 6 |
| FLAG07 | Checkpoint 7 |
Known Level Files
Race Levels (15 levels — one per tournament tier)
Note: Internal file names do NOT match display names. The XML race
tags also differ from display names (e.g.BEGINNERRACE= Warm-up Race,
CASCADERACE= Beginner Race). Always cross-reference with RaceData.xml.
| # | Display Name | XML Tag | Level File | Tier Color |
|---|---|---|---|---|
| 1 | Warm-up Race | BEGINNERRACE | Level1.MESHWORLD | Pink |
| 2 | Beginner Race | CASCADERACE | LevelCascade.MESHWORLD | Blue |
| 3 | Intermediate Race | INTERMEDIATERACE | Level2.MESHWORLD | Green |
| 4 | Dizzy Race | DIZZYRACE | Level3.MESHWORLD | — |
| 5 | Tower Race | TOWERRACE | Level4.MESHWORLD | — |
| 6 | Up Race | UPRACE | LevelUp.MESHWORLD | Red |
| 7 | Neon Race | NEONRACE | LevelDark.MESHWORLD | Orange |
| 8 | Expert Race | EXPERTRACE | Level5.MESHWORLD | — |
| 9 | odd race | ODDRACE | Level6.MESHWORLD | — |
| 10 | Toob Race | TOOBRACE | Level8.MESHWORLD | — |
| 11 | Wobbly Race | WOBBLYRACE | Level7.MESHWORLD | — |
| 12 | Glass Race | GLASSRACE | LevelGlass.MESHWORLD | — |
| 13 | Sky Race | SKYRACE | Level9.MESHWORLD | — |
| 14 | Master Race | MASTERRACE | Level10.MESHWORLD | — |
| 15 | Impossible Race | IMPOSSIBLERACE | LevelImpossible.MESHWORLD | — |
Arena Levels (15 arenas — one per race level)
| # | Display Name | Arena File |
|---|---|---|
| 1 | Warm-up Arena | Arena-WarmUp.MESHWORLD |
| 2 | Beginner Arena | Arena-Beginner.MESHWORLD |
| 3 | Intermediate Arena | Arena-Intermediate.MESHWORLD |
| 4 | Dizzy Arena | Arena-Dizzy.MESHWORLD |
| 5 | Tower Arena | Arena-Tower.MESHWORLD |
| 6 | Up Arena | Arena-Up.MESHWORLD |
| 7 | Neon Arena | Arena-Neon.MESHWORLD |
| 8 | Expert Arena | Arena-Expert.MESHWORLD |
| 9 | Odd Arena | Arena-Odd.MESHWORLD |
| 10 | Toob Arena | Arena-Toob.MESHWORLD |
| 11 | Wobbly Arena | Arena-Wobbly.MESHWORLD |
| 12 | Glass Arena | Arena-Glass.MESHWORLD |
| 13 | Sky Arena | Arena-Sky.MESHWORLD |
| 14 | Master Arena | Arena-Master.MESHWORLD |
| 15 | Impossible Arena | Arena-Impossible.MESHWORLD |
Non-playable arena files: Arena-SpawnPlatform.MESHWORLD (spawn platform),
Arena-Stands.MESHWORLD (audience stands) — not counted in the 15.
Sub-Levels (Object Prefabs)
| File | Purpose |
|---|---|
| Level3-Gluebie.MESHWORLD | Glue trap object |
| Level3-Swirl.MESHWORLD | Swirl vortex |
| Level3-Tipper.MESHWORLD | Tipping platform |
| Level3-WaterWheel.MESHWORLD | Water wheel |
| Level4-Catapult.MESHWORLD | Catapult launcher |
| Level4-Drawbridge.MESHWORLD | Drawbridge |
| Level4-Mace.MESHWORLD | Swinging mace |
| Level4-Trapdoor1/2.MESHWORLD | Trapdoor variants |
| Level4-Turret.MESHWORLD | Turret |
| Level4-Windmill.MESHWORLD | Windmill |
| Level5-Bridge.MESHWORLD | Collapsible bridge |
| Level2-Bridge.MESHWORLD | Bridge section |
| Level10-Bridge1/2.MESHWORLD | Bridge variants (Neon race) |
| Level7-Wobbly5.MESHWORLD | Wobbly platform |
| Level9-PopCylinder1/2.MESHWORLD | Pop cylinder |
| LevelUp-Lifter.MESHWORLD | Lifting platform |
| LevelUp-SpeedCylinder.MESHWORLD | Speed boost |
| LevelUp-Button.MESHWORLD | Button trigger |
| LevelImpossible-Rotator.MESHWORLD | Rotating obstacle |
| Secret.MESHWORLD | Secret area |
| Secret-Unlock.MESHWORLD | Unlock spot |
| PopupSign.MESHWORLD | Popup sign object |
| MouseTrap.MESHWORLD | Mousetrap object |
| Level6-Lifter.MESHWORLD | Level 6 lifter |
Reimplementation Notes
Level Loading
The MESHWORLD format is complex and tightly coupled to the Athena engine. For reimplementation:
- Write a
meshworld2gltfconverter that extracts geometry + objects - Parse the binary header, iterate objects, extract type strings + positions
- Store object definitions as a JSON sidecar file alongside the GLTF
- Load the geometry in-engine, spawn objects from the JSON definitions
- Each object type maps to a game entity class in the new engine
🔗 Related Documents
MESHWORLD Parser Decompilation
MESHWORLD Parser Decompilation
Function at 0x00470930, called from LoadMeshWorld (0x0045de30)
LoadMeshWorld (0x0045de30)
undefined4 __thiscall LoadMeshWorld(void *this, char *param_1)
{
// Formats filename with %s.meshworld
// If file exists: creates MeshWorld obj (0x488 bytes), calls constructor at 0x004706e0
// Then calls Parse at 0x00470930
// If load fails: displays "COULD NOT LOAD" message box and exits
}
MeshWorld Constructor (0x004706e0)
- vtable at PTR_FUN_004d9cdc
- Object is 0x488 bytes
- Initializes material array, fog colors, default params
MeshWorld::Parse (0x00470930) - Full Decompilation
/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */
uint __thiscall FUN_00470930(void *this,char *param_1,char param_2)
{
char cVar1;
float fVar2;
float fVar3;
float fVar4;
bool bVar5;
bool bVar6;
bool bVar7;
undefined4 *puVar8;
uint uVar9;
byte *pbVar10;
uint *_Str;
long lVar11;
long *plVar12;
char *pcVar13;
uint *puVar14;
float *pfVar15;
long lVar16;
undefined4 *puVar17;
undefined4 *puVar18;
undefined4 uVar19;
int iVar20;
int iVar21;
int unaff_EBP;
short sVar22;
short sVar23;
long *plVar24;
bool bVar25;
double dVar26;
double dVar27;
int local_5b8;
undefined4 *local_5b0;
undefined4 *local_5a8;
void *local_5a4;
void *local_5a0;
int local_59c;
undefined4 local_594;
undefined4 uStack_590;
undefined4 uStack_58c;
undefined4 uStack_588;
undefined4 uStack_584;
undefined4 uStack_580;
undefined4 uStack_57c;
undefined4 local_578;
undefined4 uStack_574;
undefined4 uStack_570;
float fStack_554;
float fStack_550;
float fStack_54c;
float local_548;
float local_544;
float local_540;
float local_53c;
float local_538;
float local_534;
float local_530;
float local_52c;
undefined1 local_528 [4];
char local_524 [4];
undefined1 local_520 [252];
undefined4 auStack_424 [262];
void *local_c;
undefined1 *puStack_8;
uint local_4;
local_4 = 0xffffffff;
puStack_8 = &LAB_004cd8b0;
local_c = ExceptionList;
ExceptionList = &local_c;
FUN_0047d670(&local_594);
local_4 = 0;
puVar8 = operator_new(0x874);
local_4._0_1_ = 1;
if (puVar8 == (undefined4 *)0x0) {
local_5a8 = (undefined4 *)0x0;
}
else {
local_5a8 = FUN_00458970(puVar8);
}
local_4 = (uint)local_4._1_3_ << 8;
iVar20 = -(int)param_1;
do {
cVar1 = *param_1;
param_1[(int)(local_524 + iVar20)] = cVar1;
param_1 = param_1 + 1;
} while (cVar1 != '\0');
puVar8 = (undefined4 *)(local_528 + 3);
do {
puVar17 = puVar8;
puVar8 = (undefined4 *)((int)puVar17 + 1);
} while (*(char *)((int)puVar17 + 1) != '\0');
*(undefined4 *)((int)puVar17 + 1) = DAT_004d9e58;
*(undefined1 *)((int)puVar17 + 5) = DAT_004d9e5c;
bVar7 = FUN_0047d7c0(&local_594,local_524);
if (bVar7) {
bVar7 = false;
bVar5 = false;
bVar6 = false;
pbVar10 = FUN_0047d6b0(&local_594,'\x01');
while (pbVar10 != (byte *)0x0) {
_Str = (uint *)FUN_004bc0d1(pbVar10,&DAT_004d9e54);
iVar20 = 0x10;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_COUNT";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol((char *)_Str);
*(long *)((int)this + 0x24) = lVar11;
lVar11 = _atol((char *)_Str);
plVar12 = operator_new(lVar11 * 0x50 + 4);
local_4._0_1_ = 2;
if (plVar12 == (long *)0x0) {
plVar24 = (long *)0x0;
}
else {
plVar24 = plVar12 + 1;
*plVar12 = lVar11;
_eh_vector_constructor_iterator_(plVar24,0x50,lVar11,FUN_00457fa0,FUN_00457fd0);
}
local_4 = (uint)local_4._1_3_ << 8;
*(long **)((int)this + 0x28) = plVar24;
}
iVar20 = 10;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
local_5b8 = _atol((char *)_Str);
bVar7 = true;
bVar5 = true;
bVar6 = true;
}
iVar20 = 0x12;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_AMBIENT";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if ((bVar25) && (bVar7)) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
iVar20 = (short)local_5b8 * 0x50;
dVar26 = _atof(pcVar13);
*(float *)(iVar20 + 0x14 + *(int *)((int)this + 0x28)) = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
*(float *)(iVar20 + 0x18 + *(int *)((int)this + 0x28)) = (float)dVar26;
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof((char *)_Str);
*(float *)(iVar20 + 0x1c + *(int *)((int)this + 0x28)) = (float)dVar26;
*(undefined4 *)(iVar20 + 0x20 + *(int *)((int)this + 0x28)) = 0x3f800000;
bVar7 = false;
}
iVar20 = 0x12;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_DIFFUSE";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if ((bVar25) && (bVar5)) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
iVar20 = (short)local_5b8 * 0x50;
dVar26 = _atof(pcVar13);
*(float *)(iVar20 + 4 + *(int *)((int)this + 0x28)) = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
*(float *)(iVar20 + 8 + *(int *)((int)this + 0x28)) = (float)dVar26;
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof((char *)_Str);
*(float *)(iVar20 + 0xc + *(int *)((int)this + 0x28)) = (float)dVar26;
*(undefined4 *)(iVar20 + 0x10 + *(int *)((int)this + 0x28)) = 0x3f800000;
bVar5 = false;
}
iVar20 = 0xd;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MAP_REFLECT";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
*(undefined1 *)((short)local_5b8 * 0x50 + 0x4d + *(int *)((int)this + 0x28)) = 1;
}
iVar20 = 0x13;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_SPECULAR";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if ((bVar25) && (bVar6)) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
iVar20 = (short)local_5b8 * 0x50;
dVar26 = _atof(pcVar13);
*(float *)(iVar20 + 0x24 + *(int *)((int)this + 0x28)) = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
*(float *)(iVar20 + 0x28 + *(int *)((int)this + 0x28)) = (float)dVar26;
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof((char *)_Str);
*(float *)(iVar20 + 0x2c + *(int *)((int)this + 0x28)) = (float)dVar26;
*(undefined4 *)(iVar20 + 0x30 + *(int *)((int)this + 0x28)) = 0x3f800000;
bVar6 = false;
}
iVar20 = 0xe;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_REF";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol((char *)_Str);
local_5a8[1] = lVar11;
}
iVar20 = 0x18;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_SHINESTRENGTH";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
iVar20 = (short)local_5b8 * 0x50;
pfVar15 = (float *)(iVar20 + 0x24 + *(int *)((int)this + 0x28));
dVar26 = _atof((char *)_Str);
*pfVar15 = (float)dVar26 * *pfVar15;
pfVar15 = (float *)(iVar20 + 0x28 + *(int *)((int)this + 0x28));
dVar26 = _atof((char *)_Str);
*pfVar15 = (float)dVar26 * *pfVar15;
pfVar15 = (float *)(iVar20 + 0x2c + *(int *)((int)this + 0x28));
dVar26 = _atof((char *)_Str);
*pfVar15 = (float)dVar26 * *pfVar15;
pfVar15 = (float *)(iVar20 + 0x30 + *(int *)((int)this + 0x28));
dVar26 = _atof((char *)_Str);
*pfVar15 = (float)dVar26 * *pfVar15;
}
iVar20 = 0x10;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_SHINE";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof((char *)_Str);
*(float *)((short)local_5b8 * 0x50 + 0x44 + *(int *)((int)this + 0x28)) =
(float)dVar26 * _DAT_004cf454;
}
iVar20 = 0x17;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MATERIAL_TRANSPARENCY";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
iVar20 = (short)local_5b8 * 0x50;
dVar26 = _atof((char *)_Str);
*(float *)(iVar20 + 0x10 + *(int *)((int)this + 0x28)) = _DAT_004cf310 - (float)dVar26;
dVar26 = _atof((char *)_Str);
*(float *)(iVar20 + 0x20 + *(int *)((int)this + 0x28)) = _DAT_004cf310 - (float)dVar26;
dVar26 = _atof((char *)_Str);
*(float *)(iVar20 + 0x40 + *(int *)((int)this + 0x28)) = _DAT_004cf310 - (float)dVar26;
dVar26 = _atof((char *)_Str);
*(float *)(iVar20 + 0x30 + *(int *)((int)this + 0x28)) = _DAT_004cf310 - (float)dVar26;
dVar26 = _atof((char *)_Str);
if ((double)_DAT_004cf310 - dVar26 != _DAT_004cf3c8) {
*(undefined1 *)(iVar20 + 0x4c + *(int *)((int)this + 0x28)) = 1;
}
}
iVar20 = 8;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*BITMAP";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
uVar9 = FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
_Str = (uint *)(uVar9 + 1);
puVar14 = FUN_004bacc0(_Str,'\"');
if (puVar14 != (uint *)0x0) {
*(undefined1 *)puVar14 = 0;
}
puVar8 = FUN_00455c50(*(void **)((int)this + 4),(char *)_Str,'\x01');
*(undefined4 **)((short)local_5b8 * 0x50 + 0x48 + *(int *)((int)this + 0x28)) = puVar8;
}
iVar20 = 0xc;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*GEOMOBJECT";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
puVar8 = operator_new(0x874);
local_4._0_1_ = 3;
if (puVar8 == (undefined4 *)0x0) {
local_5a8 = (undefined4 *)0x0;
}
else {
local_5a8 = FUN_00458970(puVar8);
}
local_4 = (uint)local_4._1_3_ << 8;
FUN_00453280((int)(local_5a8 + 3));
*(undefined1 *)(local_5a8 + 0x217) = 0;
FUN_00453780((void *)((int)this + 0x2c),(int)local_5a8);
}
iVar20 = 0xb;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*NODE_NAME";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
uVar9 = FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
_Str = (uint *)(uVar9 + 1);
puVar14 = FUN_004bacc0(_Str,'\"');
*(undefined1 *)puVar14 = 0;
}
iVar20 = 8;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*TM_POS";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
local_5a8[0x21a] = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
local_5a8[0x21c] = (float)dVar26;
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof((char *)_Str);
local_5a8[0x21b] = (float)dVar26;
}
iVar20 = 0x10;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_NUMVERTEX";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol((char *)_Str);
local_5a0 = operator_new(lVar11 << 5);
if (0 < lVar11) {
puVar8 = (undefined4 *)((int)local_5a0 + 0x10);
do {
puVar8[-1] = 0;
*puVar8 = 0;
puVar8[1] = 0;
puVar8[-4] = 0;
puVar8[-3] = 0;
puVar8[-2] = 0;
puVar8[2] = 0;
puVar8[3] = 0;
puVar8 = puVar8 + 8;
lVar11 = lVar11 + -1;
} while (lVar11 != 0);
}
}
iVar20 = 0x11;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_NUMTVERTEX";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol((char *)_Str);
local_5a4 = operator_new(lVar11 * 8);
iVar20 = 0;
if (0 < lVar11) {
do {
*(undefined4 *)((int)local_5a4 + iVar20 * 8) = 0;
*(undefined4 *)((int)local_5a4 + iVar20 * 8 + 4) = 0;
iVar20 = iVar20 + 1;
} while (iVar20 < lVar11);
}
}
iVar20 = 0xd;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_VERTEX";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol(pcVar13);
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
fVar2 = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
fVar3 = (float)dVar26;
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof((char *)_Str);
fVar4 = (float)dVar26;
pfVar15 = (float *)((int)local_5a0 + lVar11 * 0x20);
pfVar15[1] = fVar4;
*pfVar15 = fVar2;
pfVar15[2] = fVar3;
if (fVar2 < *(float *)((int)this + 0x45c)) {
*(float *)((int)this + 0x45c) = fVar2;
}
if (fVar4 < *(float *)((int)this + 0x460)) {
*(float *)((int)this + 0x460) = fVar4;
}
if (fVar3 < *(float *)((int)this + 0x464)) {
*(float *)((int)this + 0x464) = fVar3;
}
if (*(float *)((int)this + 0x468) < fVar2) {
*(float *)((int)this + 0x468) = fVar2;
}
if (*(float *)((int)this + 0x46c) < fVar4) {
*(float *)((int)this + 0x46c) = fVar4;
}
if (*(float *)((int)this + 0x470) < fVar3) {
*(float *)((int)this + 0x470) = fVar3;
}
}
iVar20 = 0xf;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_NUMFACES";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol((char *)_Str);
local_5b0 = operator_new(lVar11 * 0x60);
puVar8 = local_5b0;
for (uVar9 = (uint)(lVar11 * 0x60) >> 2; uVar9 != 0; uVar9 = uVar9 - 1) {
*puVar8 = 0;
puVar8 = puVar8 + 1;
}
for (iVar20 = 0; iVar20 != 0; iVar20 = iVar20 + -1) {
*(undefined1 *)puVar8 = 0;
puVar8 = (undefined4 *)((int)puVar8 + 1);
}
}
iVar20 = 0xc;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_TVERT";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol(pcVar13);
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar27 = _atof((char *)_Str);
*(float *)((int)local_5a4 + lVar11 * 8) = (float)dVar26;
*(float *)((int)local_5a4 + lVar11 * 8 + 4) = (float)dVar27;
}
iVar20 = 0xb;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_FACE";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol(pcVar13);
FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar16 = _atol(pcVar13);
puVar17 = (undefined4 *)(lVar16 * 0x20 + (int)local_5a0);
puVar8 = local_5b0 + lVar11 * 0x18;
*puVar8 = *puVar17;
puVar8[1] = puVar17[1];
puVar8[2] = puVar17[2];
puVar8[6] = puVar17[6];
puVar8[7] = puVar17[7];
FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol(pcVar13);
iVar20 = lVar11 * 0x20;
puVar8[0x10] = *(undefined4 *)(iVar20 + (int)local_5a0);
puVar8[0x11] = *(undefined4 *)((int)local_5a0 + iVar20 + 4);
puVar8[0x12] = *(undefined4 *)((int)local_5a0 + iVar20 + 8);
puVar8[0x16] = *(undefined4 *)((int)local_5a0 + iVar20 + 0x18);
puVar8[0x17] = *(undefined4 *)((int)local_5a0 + iVar20 + 0x1c);
FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol((char *)_Str);
iVar20 = lVar11 * 0x20;
puVar8[8] = *(undefined4 *)(iVar20 + (int)local_5a0);
puVar8[9] = *(undefined4 *)((int)local_5a0 + iVar20 + 4);
puVar8[10] = *(undefined4 *)((int)local_5a0 + iVar20 + 8);
puVar8[0xe] = *(undefined4 *)((int)local_5a0 + iVar20 + 0x18);
puVar8[0xf] = *(undefined4 *)((int)local_5a0 + iVar20 + 0x1c);
FUN_00453780(local_5a8 + 3,(int)puVar8);
}
iVar20 = 0xc;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_TFACE";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol(pcVar13);
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar16 = _atol(pcVar13);
local_5b0[lVar11 * 0x18 + 6] = *(undefined4 *)((int)local_5a4 + lVar16 * 8);
local_5b0[lVar11 * 0x18 + 7] =
(float)_DAT_004cf3c8 - *(float *)((int)local_5a4 + lVar16 * 8 + 4);
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar16 = _atol(pcVar13);
local_5b0[lVar11 * 0x18 + 0x16] = *(undefined4 *)((int)local_5a4 + lVar16 * 8);
local_5b0[lVar11 * 0x18 + 0x17] =
(float)_DAT_004cf3c8 - *(float *)((int)local_5a4 + lVar16 * 8 + 4);
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar16 = _atol((char *)_Str);
local_5b0[lVar11 * 0x18 + 0xe] = *(undefined4 *)((int)local_5a4 + lVar16 * 8);
local_5b0[lVar11 * 0x18 + 0xf] =
(float)_DAT_004cf3c8 - *(float *)((int)local_5a4 + lVar16 * 8 + 4);
}
iVar20 = 0x11;
bVar25 = true;
puVar14 = _Str;
pcVar13 = "*MESH_FACENORMAL";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*puVar14 == *pcVar13;
puVar14 = (uint *)((int)puVar14 + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
lVar11 = _atol(pcVar13);
local_59c = 0;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
local_548 = (float)dVar26;
dVar26 = _atof(pcVar13);
local_53c = (float)dVar26;
dVar26 = _atof(pcVar13);
local_530 = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
local_544 = (float)dVar26;
dVar26 = _atof(pcVar13);
local_538 = (float)dVar26;
dVar26 = _atof(pcVar13);
local_52c = (float)dVar26;
_Str = (uint *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof((char *)_Str);
local_540 = (float)dVar26;
dVar26 = _atof((char *)_Str);
local_534 = (float)dVar26;
dVar26 = _atof((char *)_Str);
local_528 = (undefined1 [4])(float)dVar26;
thunk_FUN_00459b24();
thunk_FUN_00459b24();
thunk_FUN_00459b24();
iVar20 = lVar11 * 0x60 + unaff_EBP;
*(undefined4 *)(iVar20 + 0xc) = uStack_590;
*(undefined4 *)(iVar20 + 0x2c) = uStack_584;
*(undefined4 *)(iVar20 + 0x4c) = local_578;
*(undefined4 *)(iVar20 + 0x10) = uStack_588;
*(undefined4 *)(iVar20 + 0x30) = uStack_57c;
*(undefined4 *)(iVar20 + 0x50) = uStack_570;
*(undefined4 *)(iVar20 + 0x14) = uStack_58c;
*(undefined4 *)(iVar20 + 0x34) = uStack_580;
*(undefined4 *)(iVar20 + 0x54) = uStack_574;
}
iVar20 = 0x13;
bVar25 = true;
pcVar13 = "*MESH_VERTEXNORMAL";
do {
if (iVar20 == 0) break;
iVar20 = iVar20 + -1;
bVar25 = (char)*_Str == *pcVar13;
_Str = (uint *)((int)_Str + 1);
pcVar13 = pcVar13 + 1;
} while (bVar25);
if (bVar25) {
FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
sVar22 = 0;
sVar23 = (short)local_59c;
if (sVar23 != 0) {
if (sVar23 == 1) {
sVar22 = 2;
}
else if (sVar23 == 2) {
sVar22 = 1;
}
}
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
fStack_554 = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
fStack_550 = (float)dVar26;
pcVar13 = (char *)FUN_004bc0d1((byte *)0x0,&DAT_004d9e54);
dVar26 = _atof(pcVar13);
fStack_54c = (float)dVar26;
thunk_FUN_00459b24();
iVar20 = ((short)local_5a0 * 3 + (int)sVar22) * 0x20 + local_5b8;
*(undefined4 *)(iVar20 + 0xc) = uStack_58c;
*(undefined4 *)(iVar20 + 0x10) = uStack_584;
local_5a4 = (void *)(local_59c + 1);
*(undefined4 *)(iVar20 + 0x14) = uStack_588;
}
pbVar10 = FUN_0047d6b0(&local_594,'\x01');
}
_free(local_5a0);
_free(local_5a4);
if (param_2 == '\x01') {
FUN_00470680((int)this);
}
else {
*(undefined4 *)((int)this + 0x34) = 0;
if (*(int *)((int)this + 0x30) < 1) {
iVar20 = 0;
}
else {
iVar20 = **(int **)((int)this + 0x438);
*(undefined4 *)((int)this + 0x34) = 1;
}
while (iVar20 != 0) {
puVar8 = (undefined4 *)0x0;
FUN_00453210(auStack_424,0);
local_4 = CONCAT31(local_4._1_3_,4);
FUN_00453280((int)auStack_424);
*(undefined4 *)(iVar20 + 0x14) = 0;
if (0 < *(int *)(iVar20 + 0x10)) {
puVar8 = (undefined4 *)**(undefined4 **)(iVar20 + 0x418);
*(undefined4 *)(iVar20 + 0x14) = 1;
}
while (puVar8 != (undefined4 *)0x0) {
puVar18 = operator_new(0x60);
puVar17 = puVar18;
for (iVar21 = 0x18; iVar21 != 0; iVar21 = iVar21 + -1) {
*puVar17 = *puVar8;
puVar8 = puVar8 + 1;
puVar17 = puVar17 + 1;
}
FUN_00453780(auStack_424,(int)puVar18);
iVar21 = *(int *)(iVar20 + 0x14);
if (*(int *)(iVar20 + 0x10) <= iVar21) break;
puVar8 = *(undefined4 **)(*(int *)(iVar20 + 0x418) + iVar21 * 4);
*(int *)(iVar20 + 0x14) = iVar21 + 1;
}
FUN_00453280(iVar20 + 0xc);
FUN_00453820((void *)(iVar20 + 0xc),(int)auStack_424);
local_4 = local_4 & 0xffffff00;
FUN_00453250(auStack_424);
iVar21 = *(int *)((int)this + 0x34);
if (*(int *)((int)this + 0x30) <= iVar21) break;
iVar20 = *(int *)(*(int *)((int)this + 0x438) + iVar21 * 4);
*(int *)((int)this + 0x34) = iVar21 + 1;
}
}
_free(local_5b0);
local_4 = 0xffffffff;
uVar19 = FUN_0047d680(&local_594);
uVar9 = CONCAT31((int3)((uint)uVar19 >> 8),1);
}
else {
local_4 = 0xffffffff;
uVar9 = FUN_0047d680(&local_594);
uVar9 = uVar9 & 0xffffff00;
}
ExceptionList = local_c;
return uVar9;
}
🔗 Related Documents
Modding Function Reference
Hamsterball Modding Function Reference
A comprehensive reference of every useful function for modders, extracted from Ghidra decompilation of Hamsterball.exe (PE32, i386, Athena engine).
Last updated: 2026-06-13
Total functions in binary: ~3,800
Table of Contents
- Game Loop & Frame Control
- App & System Initialization
- Ball Physics & Movement
- input system](#4-input-system)
- camera system](#5-camera-system)
- Scene & Object Management
- Object Spawning & Creation
- collision event system](#8-collision-event-system)
- Rendering & Graphics Pipeline
- Audio & Music System
- ui & menu system](#11-ui--menu-system)
- Save, Registry & Progression
- Arena Scoring & Timer
- Level-Specific Functions
- Utility & Math Functions
- Vtables & Calling Conventions
- Quick Reference: Most-Used Offsets
1. Game Loop & Frame Control
App_Run
- Address:
0x0046BD80 - Convention:
__fastcall(ECX =param_1) - Parameters:
param_1(int*): Pointer toAppinstance (g_Appat0x4FD680) - Description: Main game loop. Runs until
App+0x159(quit flag) is set. Every frame: Win32 message pump →App.Update(vtable0x20) →App.Render(vtable0x28) →Graphics_PresentOrEnd. - Modding use: Hook vtable slot
0x20(Update) or0x28(Render) to inject custom per-frame logic. - Key code pattern:
while (*(char*)(app + 0x159) == 0) { PeekMessageA(...); app->vtable[0x20](); // Update app->vtable[0x24](); // PreRender app->vtable[0x28](); // Render app->vtable[0x2C](); // PostRender / HUD }
Scene_Update
- Address:
0x00419C00 - Convention:
__thiscall(ECX =this) - Parameters:
this(void*):Scene*instance - Description: Central game tick. Execution order:
- Increment
frame_counter(this+0x0D88/this+0x3620) - Demo timer check (if
demo_timer_active) - ESC key → spawn
GameOverMenuif not suppressed - Ball position propagation (if
ball_positions_dirty) - Gear path following (single-gear camera mode)
- Rumble board timer ticks
- Camera shake decay (
shake_magnitude+= 10/frame toward 0) - SceneObject update+render loop
- Physics pipeline (4 vtable calls, see below)
- Level object update (
Scene_LevelObjUpdate)
- Increment
- Key offsets:
this+0x022E(int index0x8B) =scene_objects(AthenaList)this+0x0A6C(int index0x29B) =ball_positions_dirtythis+0x0A75(int index0x29D) =ball_list(AthenaList<Ball*>)this+0x0D88(int index0x362) =frame_counter
- Modding use: Hook object vtable slot
4(Update) for per-object behavior.
2. App & System Initialization
App_Initialize_Full
- Address:
0x00429530 - Convention:
__thiscall(ECX =this) - Parameters:
this(App*),param_1,param_2(both unused — WinMain params) - Description: Full 26-step game init sequence. Each step writes
"Initialize(N)"toApp+0x208:App_Initialize(base init)- Set
Graphics+0x7D1 = 1 - Load
"BLANKCURSOR"→App+0x240 vtable[0x8C](800, 600)— set display mode- Configure D3D render states (16 vs 32-bit depth)
- Load
shadow.png→App+0x278 - Load
music\music.mo3→App+0x534
8–11. Parsejukebox.xml, create music channels Registry_ReadPlayCount
13–14. Create 4InputDeviceinstances (players 1–4)
15–22. Configure input devices with mode flagsRegKey_Closevtable[0xA0]()— show title screen / main menu- Done
- Modding use: Hook after step 24 to inject custom startup code.
WinMain
- Address:
0x004278E0 - Description: Entry point. Calls
App_Initialize_Full→App_Run→App_Shutdown.
App_ShowMainMenu
- Address:
0x004280E0 - Description: Allocates
0xCDCbytes, callsMainMenu_ctor, stores atApp+0x224, adds to scene viaScene_AddObject.
App_SaveAllConfig
- Address:
0x004284C0 - Description: Writes all game settings to Windows registry:
- Display settings (
App_WriteDisplaySettings) - Mouse sensitivity (
App+0x84C) - Mirror tournament (
App+0x850) - All race/arena unlock flags (
0x851–0x868) - Best times binary blob (
0x86C, 80 bytes) - Medal status (
0x8BC, 80 bytes) - 2P controller mappings (
0xB28–0xB34)
- Display settings (
App_LoadOrSaveConfig
- Address:
0x004279F0 - Description: Called on game exit. Decides whether to save or load config based on internal state.
3. Ball Physics & Movement
Ball_Update
- Address:
0x00405E00 - Convention:
__fastcall(ECX =param_1) - Parameters:
param_1(int*):Ball*instance - Description: Per-frame ball update. Returns early if
ball+0x324(dead/eliminated). Clears per-frame flags, runs AI ifball+0x31D(is_8ball) orscene+0x237(battle mode), then calls physics (Ball_AdvancePositionOrCollision). After physics: proximity checks, trail recording, respawn state machine. - Key offsets:
ball+0x324= dead/eliminated flagball+0x31D=is_8ball/ AI enable flagball+0xC60..0xC94= AI params (home_pos, chase_distance, npc_flag)
Ball_ctor / Ball_ctor2
- Addresses:
Ball_ctor≈0x40AFE0,Ball_ctor2=0x4039E0 - Description:
Ball_ctor: allocate0xC98bytes (8ball) or0xC60(player), callGameObject_ctor, set vtable to0x4CF3A0Ball_ctor2: set physics defaults — gravity0.5, radius26.0, max_speed5.0- Player ball:
operator_new(0xC60)→Ball_ctor2(this, scene)→vtable[4]()→Ball_SetTrajectory
Ball_GetInputForce
- Address:
0x46EC30 - Parameters:
this(InputDevice*),output(float*) →[force_x, force_y] - Description: Converts input device state to 2D force vector. Reads DIK codes at
InputDevice+0x50C/0x510/0x514/0x518. - Key offsets:
this+0x08= input mode
Ball_AdvancePositionOrCollision
- Address:
0x4564C0 - Parameters:
this(Ball*),out_pos,cur_pos,input_vel,collision_flags,dt - Description: Core physics step. Velocity integration → damp → gravity →
TestSphereVsLevelcollision → bounce → trail. - Key physics globals:
0x4CF3F0= damping constant (0.95)0x4CF3E8= ice friction factor (6.0)0x4CF374= force multiplier on ice (0.2)0x4CF380= force multiplier after first frame (0.25)
Ball_SetTargetPos
- Address:
0x00402030 - Parameters:
this(Ball*),x,y,z(floats) - Description: Sets
ball+0x758/75C/760— camera orbit center point (smoothed display position).
Ball_Shrink
- Address:
0x402200 - Description: odd race E:SHRINK collision handler. Sets
ball+0xC4C = 1(is_shrunk),ball+0x284 = 13.0(shrunk radius), plays fall sound.
Ball_FallUpdate
- Address:
0x408830 - Description: Physics update while falling. Decrements
fall_timer(ball+0x80C). When timer expires, callsBall_FindClosestRespawnPoint.
Ball_FindClosestRespawnPoint
- Address:
0x405190 - Description: Scans
scene+0x546(SAFESPOT/SAFEPOS list) for nearest valid respawn position. Writes new position toball+0x164..16C.
Ball_Shatter
- Address:
0x408D70 - Convention:
__thiscall(ECX = Ball* — the parent ball being replaced) - Called from:
FollowBall_Update(0x43ECC0) — NOT from E:JUMP or any collision handler - Description: Arena mechanic that replaces the parent ball with 3 AI-controlled split balls. The parent ball is marked for despawn (
+0x2E8 = 1), then 3 newBall_Splitobjects are created viaBall_Split_ctor(0x408D10). Each split ball gets:- Allocation:
0xC64bytes ball+0x31D = 1(is_8ball flag, activates AI)ball+0xC6 = 10.0(0x41200000 — split ball size)- Split IDs: 1, 2, 4 (bitmask, one per iteration)
- Trajectory from parent's
+0x2AC–0x2B8 - Added to
scene+0x3204(ball list)
- Allocation:
- Guard conditions:
+0x324 == 0(not already an 8-ball) AND+0x744 == 0(hasn't split yet)
Ball_InitBattleMode
- Address:
0x456CD0 - Description: Converts ball to battle mode. Sets:
ball+0xC60 = 3(battle flag)ball+0xC68 = 0.55(high friction)ball+0xC6C = 1.0(bouncy)ball+0xC70 = 1000.0(max speed — 200x normal!)
4. Input System
Input_IsKeyDown
- Address:
0x46E0B0 - Parameters:
key(int) — DIK scancode - Returns: 1 if held, 0 if not
App_CreateInputDevice
- Address:
0x0046C050 - Returns:
InputDevice*(0x14 bytes) - Key offsets:
+0x00vtable,+0x04App*,+0x08input mode,+0x10joystick ptr
App_CreateInputHandler
- Address:
0x0046C110 - Returns:
InputHandler*— aggregates 4InputDeviceinstances
InputDevice_SetType
- Address:
0x46DFC0 - Parameters:
this(InputDevice*),mode(int) — 1=keyboard, 2=mouse, 4-7=joystick
5. Camera System
Scene_SetCamera
- Address:
0x00419FA0 - Convention:
__thiscall(ECX =this) - Parameters:
this(Scene*),param_1(Ball*),param_2(char) — if true, apply path-following spring - Description: Camera positioning every frame. 5 modes:
- Default Follow: Orbit around
ball+0x758+scene+0x434Coffset - Path Rail: If
scene+0x3F1C != 0ANDparam_2 != 0→ callPath_GetPosition(scene+0x3F20, &out, scene+0x3F24) - Camera Shake: If
ball+0x744: random ±50 per axis - Snap: If
scene+0x3F2C > 0: force camera to ball physics pos, decrement - Orbit: Apply
scene+0x29BC(angle) andscene+0x29C0(distance)
- Default Follow: Orbit around
- Key offsets:
scene+0x3F1C=path_follow_modescene+0x3F20=path_objectscene+0x3F24=path_positionscene+0x3F2C=camera_snap_framesscene+0x29BC=camera_orbit_anglescene+0x29C0=camera_distance(max 700)scene+0x434C=camera_offset(Vec3)
6. Scene & Object Management
Scene_AddObject
- Address:
0x00469990 - Parameters:
this(Scene*),obj(Gadget*) - Description: Appends to
scene+0x2578(active object list). Objects receiveUpdate(vtable[4]) andRender(vtable[0]) per frame.
Scene_SpawnBallsAndObjects
- Address:
0x0041C5B0 - Description: Level startup factory. Creates all game objects:
- Ball creation loop — lookup
"START%d-%d"in hash table,Ball_ctor2, set properties - Safe spot scan —
"SAFESPOT"/"SAFEPOS"→scene+0x546 - Tournament/demo:
CreateBadBall,CreateMouseTrap - Decoration:
CreateSecretObjects,Scene_CreateFlags,Scene_CreateSigns,Scene_CreateDynamicObjects
- Ball creation loop — lookup
- Ball defaults: radius=26.0, max_speed=5.0, gravity_scale=0.5
Board_ctor
- Address:
0x00419030 - Description: Creates Board (~0x5400 bytes, inherits Gadget). Sets up scene lists, ball list, D3D device, level slots.
Scene_CleanupScene
- Address:
0x419740 - Description: Destructor helper. Cleans all AthenaLists in reverse order: collision_list → ripple_list → ball_list → scene_object_list → etc.
7. Object Spawning & Creation
CreateLevelObjects (Object Factory)
- Address:
0x004121D0 - Convention:
__thiscall(ECX =this) - Parameters:
this(Scene*),name(char*),param_2(matrix),param_3(matrix),param_4(index) - Description: Main object factory. Matches prefixes via
__strnicmp:BRIDGE→scene+0x436CTIPPER→Tipper_ctor(0x1104)+ visualBONK→Bonk_ctor(0x1200)→scene+0x540CBBRIDGE1/BBRIDGE2→BreakBridge_ctor(0x1100)POPCYLINDER→PopCylinder_ctor(0x10E8)BLOCKDAWG1/BLOCKDAWG2→Blockdawg_ctor(0x1154)CATAPULT→Catapult_ctor(0x1108)→scene+0x584CGLUEBIE→Gluebie_ctor(0x110C)→scene+0x6080SAFESPOT/SAFEPOS→ spawn positionsBADBALL→ 8-ball AI parameters
CreateBadBall
- Address:
0x0040BCA0 - Description: Scans meshworld for
"BADBALL". For each:Ball_ctor(new(0xC98), scene)— note: larger than player ball!- Parse tags:
<CHASE>,<HOME>,<SIZE>,<SPINDISTANCE> - Append to
scene+0x29D4(8ball list) andscene+0x2DEC(active ball list)
CreateBumper
- Address:
0x0040FA20 - Description: Creates 8 bumpers for Level 8. Loads
levels\level8, callsScene_CollectByNameFilter("N:BUMPER%d").
CreateBumper2
- Address:
0x00413CE0 - Description: Arena variant — 4 bumpers for beginner arena.
CreateMouseTrap
- Address:
0x0040BF50 - Description: Scans meshworld for
"MOUSETRAP". CreatesTipperVisualobjects appended to collision and render lists.
CreateBonkPopup
- Address:
0x00438B30 - Description: Hammer hit visual feedback. Plays 3D sound, calls
vtable[0x88]("BONKPOPUP").
Hammer_ChaseStart
- Address:
0x00438BB0 - Description: Starts hammer AI chase. Sets chase flags, copies position/target, chase speed = 0.5.
8. Collision Event System
DispatchCollisionEvents / DispatchCollisionEvents
- Address:
0x0040C5D0 - Convention:
__thiscall(ECX = this) - Parameters:
this(Level*),ball(Ball*),collider(CollisionObject*) - Description: Universal collision event dispatcher. Event name at
collider[1]+0x864.
| Event | Condition | Action |
|---|---|---|
N:SECRET |
— | Rotator_MarkTriggered |
N:UNLOCKSECRET |
— | CheckArenaUnlock(scene) |
E:NODIZZY<TIME>N</TIME> |
— | Ball_DizzyImmunity(ball, duration) |
E:SAFESWITCH(data) |
— | Copy data to ball+0xC2C |
E:LIMIT |
— | Clear ball+0x1DA, set ball+0x2E9=1 |
E:BREAK |
— | ball->vtable[0x20]() bounce callback |
E:JUMP |
impactCounter < 1 |
Sound, force 0.025, freeze 10 frames, Ball_DizzyImmunity(+200) |
E:ACTION<ONCE>TRUE</ONCE><SCORE>N</SCORE> |
— | Check duplicate at ball+0xCB, award score |
E:TRAJECTORY<X>..</X> |
— | Set collision direction ball+0xCA4/CA8/CAC |
N:NOCONTROL |
— | ball+0x202 = 10 (disable input 10 frames) |
N:WATER |
— | ball+0x2D5 = 1, ball+0xB6 = 10 |
N:TARPIT |
first time | Play tar sound, ball+0xB3 = 1 |
DROPIN |
dist > threshold |
Sound, dropinCounter = 50, Ball_DizzyImmunity(+200) |
PIPEBONK |
counter < 1 |
Random sound, counter = 10, Ball_DizzyImmunity(+100) |
POPOUT |
counter < 1 |
Sound, counter = 50, Ball_DizzyImmunity(+100) |
N:GOAL |
!finished && active |
Set goalReached=1, play music, mark finished |
N:MOUSETRAP |
— | Randomize RNG, deflect direction × trap speed |
TowerCollisionEvents
- Address:
0x40DCD0 - Description: Level-specific events, then delegates to
DispatchCollisionEvents:E:CATAPULTBOTTOM→ launch catapultE:OPENSESAME→ open all trapdoorsN:TRAPDOOR→ activate trapdoorE:BITE→scene+0x43A0 = 25.0E:MACETRIGGER→ activate maces
ExpertCollisionEvents
- Address:
0x40E6A0 - Description: Arena-specific events (see
docs/ARENA_SCORING.mdfor full table):E:CALLHAMMER→CreateBonkPopupE:HAMMERCHASE→Hammer_ChaseStartE:ALERTSAW1/2→Saw_AlertActivateE:ACTIVATESAW1/2→Saw_ActivateE:ALERTJUDGES→ reset all judgesE:SCORE<number>→ parse time,ScoreDisplay_SetTimeE:BELL→Bell_Activate, +500 time, "EXTRA TIME:" popupE:JUMP→ jump pad
9. Rendering & Graphics Pipeline
Scene_Render
- Address:
0x0041A2E0 - Convention:
__thiscall - Description: Main render dispatch. Per viewport:
Graphics_SetViewportScene_SetCameravtable[0x60]— Background (sky/dome)vtable[0x64]— Opaque geometryvtable[0x68]— Transparent objectsvtable[0x70]— Overlay (HUD)vtable[0x6C]— PostEffects (fade/transition)
Scene_RenderAllObjects
- Address:
0x0045E0E0 - Description: Iterates
scene+0x22E(AthenaList), callsobj->vtable[0]()(Render) for each.
Ball_Render
- Address:
0x402DE0 - Description: D3D8 ball rendering. Sets up render state, applies world transform from
ball+0xC88(4×4 matrix), draws sphere mesh with texture.
Ball_RenderShadow
- Address:
0x401920 - Description: Draws shadow quad beneath ball. Uses
shadowTexture(App+0x278), alpha blend at ball position with Y offset.
Ball_CreateTrailParticles
- Address:
0x401DD0 - Description: Creates trail of particles behind moving ball. Appends to
scene+0x3B00(trail_particles_ptr).
Level_UpdateAndRender
- Address:
0x40B600(vtable[0x64]) - Description: Two-pass rendering: opaque then alpha. Updates waypoint arrow, renders visible objects.
Level_RenderObjects
- Address:
0x40B570(vtable[0x68]) - Description: Transparent pass. Glass, water, effects.
Level_RenderDynamicObjects
- Address:
0x40B420(vtable[0x60]) - Description: Sky/dome + water ripples + dynamic object callback.
Graphics_Initialize
- Address:
0x455380 - Description: Creates D3D8 device, enumerates display modes, sets up render targets.
Gfx_SetAlphaBlendState
- Address:
0x425FE0 - Description: Toggles
D3DRS_ALPHABLENDENABLE.
Gfx_SetCullMode
- Address:
0x427940 - Description: Sets
D3DRS_CULLMODE.
10. Audio & Music System
Audio_PlayMusic
- Address: inferred from
MusicPlayer_ctorusage - Description: Plays BASS music stream. Music handle at
App+0x534.
Audio_PlayMusicAtSpeed
- Address: inferred
- Parameters:
musicHandle,trackName(char*),speed(float) - Description: Plays music at modified tempo. Used by
MusicPlayer_ctorfor "Main Theme - No Intro" at 2.0× speed.
MusicPlayer_ctor
- Address:
0x426030 - Parameters:
this,App*,bool skipIntro - Description: Creates music player gadget. If
skipIntro==false: plays"Main Theme". Iftrue: plays"Main Theme - No Intro"at 2.0×.
Sound_Play3D
- Address: inferred from multiple call sites
- Parameters:
soundHandle,x,y,z(floats) - Description: Plays positional 3D audio effect at world coordinates.
Level_ReadSoundVolume
- Address:
0x466570 - Description: Reads
"Sound Volume"from registry, defaults to1.0.
SoundDevice_dtor
- Address:
0x4668A0 - Description: Writes current volume back to registry before cleanup.
11. UI & Menu System
MainMenu_ctor
- Address:
0x42DE50 - Description: Creates main menu gadget (~0xCDC bytes). Buttons: Play, Practice, Options, Quit.
PauseMenu_Ctor
- Address:
0x42E4B0 - Description: Creates pause menu overlay. Triggered by ESC during gameplay.
OptionsMenu_RenderControls
- Address:
0x42E840/0x42E910 - Description: Renders control remapping UI. Reads/writes
App+0xB28–0xB34(2P controller mappings).
PracticeMenu_ctor
- Address:
0x42EA30 - Description: Practice mode menu — level select without tournament constraints.
TimeTrialMenu_ctor
- Address:
0x42F810 - Description: Time trial menu — race against ghost data.
ArenaMenu_ctor
- Address:
0x42FC40 - Description: Arena mode menu — multiplayer level select.
PartyMenu_ctor
- Address:
0x42FC10 - Description: Party mode menu.
DifficultyMenu_ctor
- Address:
0x42E220 - Description: Difficulty selection menu (affects AI speed/timing).
TourneyMenu_GetRaceName
- Address:
0x4264A0 - Returns:
char*— current race level name string
UI_DrawTextCentered
- Address:
0x409C60 - Description: Draws centered text at screen position.
UI_DrawTextCenteredAbsolute
- Address:
0x4013A0 - Description: Draws centered text with absolute pixel coordinates.
UI_DrawTextShadow
- Address:
0x4012C0 - Description: Draws text with drop shadow effect.
UI_DrawTextShadow_Wrapper
- Address:
0x409B90 - Description: Wrapper for shadow text with additional parameters.
Font_DrawCentered
- Address:
0x42C870 - Description: Renders centered string using game font.
12. Save, Registry & Progression
TourneyMenu_WriteSave
- Address:
0x4264B0 - Description: Writes
DATA\TOURNAMENT.SAV(~151 bytes):Profile+0x08(4 bytes) — current_raceProfile+0x14(4 bytes)Profile+0x18(60 bytes) — race_time_array[15]Profile+0x54(60 bytes) — race_time_array_2[15]Profile+0x90(4 bytes) — accumulated_timeProfile+0x94(1 byte) — difficultyProfile+0x95(1 byte) — rollback flagApp+0x236(1 byte) — mirror modeApp+0x23C(4 bytes) — race activeApp+0x5E8(4 bytes) — total timeApp+0x5E4(4 bytes) — ranking timeApp+0x5F4(4 bytes)
TourneyMenu_LoadSaveAndShow
- Address:
0x4265A0 - Description: Reads
DATA\TOURNAMENT.SAVwith same field order, then createsTourneyMenu.
LoadConfig
- Address:
0x42AE80 - Description: Loads display settings from registry on startup.
SaveConfig
- Address:
0x42B6E0 - Description: Saves display settings to registry.
CheckPurchaseOrHighScore
- Address:
0x40A420 - Description: Shareware nag screen. If not registered:
- Creates
ConfirmMenuwith "BUY HAMSTERBALL..." text - Or
HighScoreEntryif game in progress - Registered version: skips dialog, allows save
- Creates
CheckArenaUnlock
- Address:
0x40ABA0 - Description: Checks if arena levels should be unlocked based on race completion progress.
LoadRaceData
- Address:
0x40A120 - Description: Parses
racedata.xmlfor medal thresholds:TIME→ target timeGOLD/SILVER/BRONZE→ medal cutoffs (stored as9 - value)WEASEL→ weasel time threshold- Reads per-level data based on level name parameter
13. Arena Scoring & Timer
Full documentation: See
docs/ARENA_SCORING.md
ArenaBoard_Update
- Address:
0x421FE0 - Description: Per-frame arena update. Checks timer expiration, computes winner, handles tie-breaker.
ArenaBoard_Render
- Address:
0x421910 - Description: Draws countdown timer, 4-player HUD, tie-breaker overlay.
Rotator_AddBall
- Address:
0x43B6F0 - Signature:
__thiscall Rotator_AddBall(Scene* scene, Ball* ball) - Description: Registers ball on rotator's ball-tracking AthenaList (at
scene+0x10F0). Allocates 8-byte entry[ball_ptr, tick_counter=10]. If ball already in list, resets tick to 10. Called from collision handlers onN:ONROTATOR(Impossible),N:SPINNY(Toob),N:SWIRL(Dizzy arena). Tick counter decremented each frame byCatapult_Update(0x43E600); resets to 10 on every frame of continued contact (10-frame grace period after leaving). Formerly misnamedScoreObject_SetScore— has nothing to do with scoring.
ScoreObject_ctor
- Address:
0x44BE80 - Parameters:
this,App*,playerData*,label(char*) - Size:
0x30bytes - Description: Creates a SceneObject with vtable
PTR_RaceGoalReached_Render(0x4D6C70). Used for race goal rendering and as the container type for rotator ball tracking.
ScoreDisplay_SetTime
- Address:
0x434C80 - Description: Sets displayed timer string with randomized decimal variation.
14. Level-Specific Functions
Each level has a custom BoardLevel subclass with constructor and destructor:
WarmUp (Level 1)
- Ctor:
LevelBoard_WarmUp_ctor@0x41CA40 - Dtor:
LevelBoard_WarmUp_dtor@0x41CB10
Intermediate (Level 2)
- Ctor:
LevelBoard_Intermediate_ctor@0x41CB20 - Dtor:
LevelBoard_Intermediate_dtor@0x41CC80
Dizzy (Level 4)
- Ctor:
LevelBoard_Dizzy_ctor@0x41D060(note: named Level3 in code) - Dtor:
LevelBoard_Dizzy_dtor@0x41D450
Tower (Level 5)
- Ctor:
LevelBoard_Tower_ctor@0x41E340 - Dtor:
LevelBoard_Tower_dtor@0x41E640
Expert (Level 8)
- Ctor:
LevelBoard_Expert_ctor@0x41EA40 - Dtor:
LevelBoard_Expert_dtor@0x41EC90
Odd (Level 9)
- Ctor:
LevelBoard_Odd_ctor@0x41ED80 - Dtor:
LevelBoard_Odd_dtor@0x41EE70
Wobbly (Level 12)
- Ctor:
LevelBoard_Wobbly_ctor@0x41F110 - Dtor:
BoardLevel12_Wobbly_dtor@0x41F3C0
Toob
- Ctor:
LevelBoard_Toob_ctor@0x41F4B0 - Dtor:
BoardLevel_Toob_dtor@0x41F720
Sky
- Ctor:
LevelBoard_Sky_ctor@0x41F930 - Dtor:
BoardLevel_Sky_Dtor@0x41FBC0
Beginner
- Ctor:
LevelBoard_Beginner_ctor@0x4200E0 - Dtor:
BoardLevel_Beginner_Dtor@0x4201D0 - HandleRaceEnd:
Board_Beginner_HandleRaceEnd@0x420240
Up
- Ctor:
LevelBoard_Up_ctor@0x420390 - Dtor:
BoardLevel_Up_Dtor@0x420550
Arena Level Constructors (via TourneyMenu_CreateBoard)
| ID | Arena | Ctor Name | Size |
|---|---|---|---|
| 1 | WarmUp | ArenaBoard_WarmUp_ctor |
0x47E0 |
| 2 | Beginner | ArenaBoard_Beginner_ctor |
0x5850 |
| 3 | Intermediate | ArenaBoard_Intermediate_ctor |
0x47E0 |
| 4 | Dizzy | ArenaBoard_Dizzy_ctor |
0x47E4 |
| 5 | Tower | ArenaBoard_Tower_ctor |
0x501C |
| 6 | UpArena | ArenaBoard_Up_ctor |
0x47E4 |
| 7 | NeonArena | ArenaBoard_Neon_ctor |
0x47E8 |
| 8 | ExpertArena | ArenaBoard_Expert_ctor |
0x4BFC |
| 9 | OddArena | ArenaBoard_Odd_ctor |
0x47E0 |
| 10 | ToobArena | ArenaBoard_Toob_ctor |
0x5C6C |
| 11 | WobblyArena | ArenaBoard_Wobbly_ctor |
0x47E4 |
| 12 | Glass | BoardLevel_Glass_ctor |
0x47E0 |
| 13 | SkyArena | ArenaBoard_Sky_ctor |
0x4CFC |
| 14 | WarmupArena | ArenaBoard_Master_ctor |
0x47E0 |
| 15 | Impossible | ArenaBoard_Impossible_ctor |
0x47E4 |
TourneyMenu_CreateBoard
- Address:
0x426780 - Description: Giant switch statement (cases 1–15) that allocates and constructs the correct
ArenaBoardsubclass for the selected arena level.
15. Utility & Math Functions
Vec3 Operations
| Function | Address | Description |
|---|---|---|
Vec3_Copy |
0x401010 |
Copy vector |
Vec3_Init |
0x401040 |
Initialize to zero |
Vec3_dtor |
0x401070 |
Destructor (no-op) |
Vec3_Scale |
0x4016C0 |
Multiply by scalar |
Vec3_DivideByScalar |
0x401890 |
Component-wise divide |
Vec3_AddTwo |
0x4018C0 |
Add two vectors |
Vec3_AddInPlace |
0x4018F0 |
a += b |
Vec3_Length |
0x401A60 |
Euclidean length |
Vec3_NormalizeAndScale |
0x401AA0 |
Normalize then scale |
Vec3_Distance |
0x401D20 |
Distance between two vectors |
Matrix Operations
| Function | Address | Description |
|---|---|---|
Matrix_TransformVec3 |
0x401D60 |
4×4 matrix × vector |
Matrix_TransformPoint2D |
0x45C273 |
2D point transform |
Math Utilities
| Function | Address | Description |
|---|---|---|
Gfx_PackColorRGB |
0x401100 |
Pack R,G,B into DWORD |
RNG_Rand |
inferred | Random number generator |
Wave_Sin / Wave_Cos |
inferred | Sine/cosine wave functions |
SQRT |
inferred | Square root |
ABS |
inferred | Absolute value |
16. Vtables & Calling Conventions
Ball Vtable (0x4CF3A0)
| Slot | Offset | Function | Purpose |
|---|---|---|---|
| 0 | 0x00 |
Ball_dtor |
Destructor |
| 1 | 0x04 |
Ball_Init |
Post-constructor setup |
| 4 | 0x10 |
Ball_Update |
Per-frame physics tick |
| 5 | 0x14 |
ApplyForceWithMultipliers |
Force with scale |
| 7 | 0x1C |
CollisionHandler |
Custom collision response |
| 8 | 0x20 |
BounceCallback |
Called on E:BREAK events |
| 30 | 0x78 |
PreSplitCallback |
Before Ball_Shatter |
| 32 | 0x80 |
PostFactoryInit |
After bumper/sawblade creation |
Scene/Board Vtable (0x4D0260)
| Slot | Offset | Function | Purpose |
|---|---|---|---|
| 0 | 0x00 |
Scene_ctor |
Constructor |
| 4 | 0x10 |
Scene_Update |
Main game tick |
| 8 | 0x20 |
Scene_RunTick |
Single tick (Update+Render) |
| 10 | 0x28 |
Scene_Render |
Full render dispatch |
| 12 | 0x30 |
Scene_LoadLevel |
Level loading |
| 16 | 0x40 |
Scene_CleanupScene |
Cleanup |
| 18 | 0x48 |
Scene_StartRace |
Race countdown start |
| 19 | 0x4C |
Scene_HandleRaceEnd |
Check finish |
| 20 | 0x50 |
Scene_UpdateBallsAndState |
Ball physics |
| 22 | 0x58 |
Scene_ProcessRaceEnd |
Countdown timer |
| 24 | 0x60 |
RenderBackground |
Sky/dome |
| 25 | 0x64 |
RenderOpaque |
Opaque geometry |
| 26 | 0x68 |
RenderTransparent |
Glass/effects |
| 28 | 0x70 |
RenderOverlay |
HUD |
| 29 | 0x74 |
RenderPostEffects |
Fade/transition |
| 33 | 0x84 |
CreateDynamicObject |
Level-specific factory override |
App Vtable (0x4CE400)
| Slot | Offset | Function | Purpose |
|---|---|---|---|
| 0 | 0x00 |
App_ScalarDtor |
Destructor |
| 2 | 0x08 |
App_Shutdown |
Cleanup on exit |
| 8 | 0x20 |
Update |
Game logic (Scene_Update) |
| 9 | 0x24 |
PreRender |
Camera setup |
| 10 | 0x28 |
Render |
Draw scene |
| 11 | 0x2C |
PostRender |
HUD / menus |
| 35 | 0x8C |
SetDisplayMode |
Resolution change |
| 40 | 0xA0 |
ShowMainMenu |
Title screen |
Calling Conventions
__thiscall: ECX =thispointer. Used for all object methods.__fastcall: ECX = first arg, EDX = second arg. Used for inner loops.- Standard cdecl: Stack-based args. Used for utility functions.
Quick Reference: Most-Used Offsets
Ball Struct (0xC98 bytes for 8ball, 0xC60 for player, vtable 0x4CF3A0)
| Offset | Type | Name | Description |
|---|---|---|---|
+0x00 |
void** | vtable | 0x4CF3A0 |
+0x04 |
void* | scene | Parent Scene* |
+0x14 |
int32 | player_index | -1=AI, 0=P1, 1=P2 |
+0x18 |
char[0x14] | rumble_timer1 | ArenaBoard timer sub-object |
+0x60 |
float[3] | position | Physics position (X, Y, Z) |
+0x150 |
float | accumulated_time | Delta-time accumulator |
+0x158 |
float[3] | prev_pos | Previous frame position |
+0x164 |
float[3] | display_pos | Smoothed display position |
+0x170 |
float[3] | velocity | Current velocity |
+0x17C |
float[3] | acceleration | Cleared each frame |
+0x188 |
float | max_speed | Hard velocity cap (default 5.0) |
+0x18C |
float | speed_scale | Global speed multiplier (default 1.0) |
+0x1A4 |
void* | collision_mesh | CollisionMesh ptr |
+0x1A8 |
float[3] | gravity_vec | Gravity direction vector |
+0x1C8 |
float | render_alpha | Render context alpha (0.75) |
+0x20C |
float[4] | color | RGBA tint (default 1,1,1,1) |
+0x254 |
uint8 | uses_alpha | True if color_a != 1.0 |
+0x260 |
uint8 | boost_hit_flag | Set on boost pad contact |
+0x278 |
float | gravity_scale | Gravity multiplier (default 0.1) |
+0x281 |
uint8 | unused_init_flag | DEAD: set to 1 in ctor, never read |
+0x284 |
float | radius | Collision + render size (default 26.0) |
+0x2A4 |
float | spin_rate | Angular spin factor (5.0) |
+0x2BC |
float[3] | force | Accumulated input force |
+0x2CC |
uint8 | force_disable | 1 = skip Ball_ApplyForce |
+0x2DC |
float[3] | checkpoint | Last safe position |
+0x2E8 |
uint8 | splitting | Set during Ball_Shatter |
+0x2F0 |
uint32 | force_count | Forces applied this frame |
+0x2F9 |
uint8 | frozen | Stuck on surface |
+0x2FC |
uint32 | freeze_timer | Countdown while frozen |
+0x310 |
uint8 | state_active | General active flag |
+0x318 |
float | split_timer | Countdown for split balls (30.0) |
+0x31D |
uint8 | is_8ball | AI enable flag |
+0x324 |
uint8 | dead | Eliminated / in-tube |
+0x748 |
int32 | gravity_plane | 0=flat, 1=tilted, 2=vertical |
+0x758 |
float[3] | camera_target | Camera orbit center |
+0x76C |
float[3] | camera_actual | Camera position (written by Scene_SetCamera) |
+0xC28 |
char** | display_string | Floating text above ball |
+0xC3C |
uint8 | teleport_active | Teleport in progress |
+0xC40 |
float[3] | teleport_dest | Destination coordinates |
+0xC4C |
uint8 | is_shrunk | odd race shrunk state (E:SHRINK=1, E:GROW=0) |
+0xC60 |
int32 | battle_mode | 3=battle, 5=split |
+0xC68 |
float | friction | 0.55 in battle mode |
+0xC6C |
float | bounciness | 1.0 in battle mode |
+0xC70 |
float | max_speed_battle | 1000.0 in battle mode |
+0xC74 |
float | chase_distance | AI chase radius (25.0 battle) |
+0xC80 |
float[5] | gravity_vec_battle | Battle gravity (0,-1.0,0,...) |
+0xCA0 |
float | speed_scale_split | 0.01 for split balls |
+0xCA4 |
float[3] | collision_dir | Last collision direction |
+0xC88 |
float[16] | world_matrix | 4×4 transform for rendering |
Scene Struct (~0x5400 bytes, vtable 0x4D0260)
| Offset | Type | Name | Description |
|---|---|---|---|
+0x0000 |
void** | vtable | 0x4D0260 |
+0x0874 |
byte | is_skydome | 0=skybox, 1=skydome |
+0x0878 |
App* | scene_manager | D3D device / App back-pointer |
+0x087C |
void* | viewport_obj | D3D viewport |
+0x08B8 |
AthenaList | scene_object_list | All scene objects |
+0x08BC |
int | scene_object_count | Number of objects |
+0x0CC4 |
SceneObject** | scene_object_array | Direct pointer array |
+0x1518 |
AthenaList | collision_list | Collision surfaces |
+0x2160 |
AthenaList | ripple_list | Water ripples |
+0x29B0 |
byte | ball_positions_dirty | Need propagate this frame |
+0x29B8 |
int | shake_magnitude | Camera shake (-800→0) |
+0x29BC |
float | camera_orbit_angle | Y-axis rotation |
+0x29C0 |
float | camera_distance | Orbit distance (max 700) |
+0x29D0 |
Ball* | current_ball_ptr | Camera-tracked ball |
+0x29D4 |
AthenaList | ball_list_1 | Player 1 balls |
+0x29D8 |
int | ball_list_1_count | P1 ball count |
+0x2DE0 |
Ball** | ball_list_1_array | P1 Ball pointer array |
+0x3204 |
AthenaList | ball_list_2 | Player 2 balls |
+0x3208 |
int | ball_list_2_count | P2 ball count |
+0x3610 |
Ball** | ball_list_2_array | P2 Ball pointer array |
+0x361C |
SceneObject* | waypoint_arrow | Next checkpoint arrow |
+0x3620 |
int | frame_counter | Total frames |
+0x362C |
AthenaList | player_list | Player viewport list |
+0x3630 |
int | player_count | 0=none, 1=SP, 2=split |
+0x3A38 |
Ball** | player_ball_array | Indexed by player (0–3) |
+0x3A48 |
AthenaList | visible_object_list | Render bucket |
+0x3A4C |
byte | shake_active | Camera shake active |
+0x3AFC |
void* | dynamic_object | Post-update callback obj |
+0x3F1C |
byte | path_follow_mode | 1=camera rides spline rails |
+0x3F20 |
void* | path_object | Spline Path* |
+0x3F24 |
float | path_position | Parametric t (0.0–1.0) |
+0x3F2C |
int | camera_snap_frames | Snap countdown |
+0x434C |
float[3] | camera_offset | Added to ball pos |
+0x4358 |
byte | demo_timer_active | Demo countdown running |
+0x435C |
int | demo_countdown | Frames remaining |
+0x436C |
void* | hammer_obj | Arena hammer |
+0x4370 |
void* | saw1_obj | Saw blade 1 |
+0x4374 |
void* | saw2_obj | Saw blade 2 |
+0x43A0 |
float | damage_amount | From E:BITE |
+0x43A8 |
int | damage_timer | Damage countdown |
+0x43B8 |
void* | catapult_list | Catapult objects |
+0x47D0 |
void* | door_list | Trapdoor list |
+0x4BBC |
void* | judge_list | Judge/score displays |
+0x4FD4 |
void* | bell_obj | Bell (extra time) |
App Struct (singleton at 0x4FD680, vtable 0x4CE400)
| Offset | Type | Name | Description |
|---|---|---|---|
+0x000 |
void** | vtable | 0x4CE400 |
+0x004 |
HINSTANCE | hInstance | WinMain param |
+0x054 |
RegKey* | registryKey | Registry handle |
+0x05C |
int | targetFPS | Target frame rate (30) |
+0x159 |
bool | quitFlag | 1 = exit loop |
+0x15A |
bool | activeFlag | Window focused |
+0x158 |
bool | minimizedFlag | Window minimized |
+0x156 |
bool | updateDisabled | Pause all updates |
+0x15C |
int | width | Window width (800) |
+0x160 |
int | height | Window height (600) |
+0x174 |
Graphics* | graphics | D3D8 engine |
+0x17C |
AudioSystem* | audioSystem | BASS audio |
+0x180 |
InputHandler* | inputHandler | DirectInput8 |
+0x184 |
void* | gameUpdateObj | Passed to tick |
+0x1B4 |
char* | versionString | ProductVersion |
+0x1CC |
int | loadedCount | Objects loaded |
+0x200 |
bool | initialized | 1 after init |
+0x208 |
char* | initStep | "Initialize(1)".."(26)" |
+0x224 |
void* | mainMenuObj | MainMenu instance |
+0x228 |
void* | resultsScreen | Race results |
+0x234 |
bool | is_demo | Demo mode |
+0x237 |
bool | is_2player | Multiplayer |
+0x238 |
bool | rightButtonPause | Right-click pause |
+0x23C |
int | is_tournament | Tournament/arena active |
+0x278 |
Texture* | shadowTexture | shadow.png |
+0x534 |
HMUSIC | musicHandle | BASS music |
+0x538 |
HCHANNEL | musicChannel1 | BASS channel 1 |
+0x53C |
HCHANNEL | musicChannel2 | BASS channel 2 |
+0x550 |
void* | gameMode1 | 1-player mode |
+0x554 |
void* | gameMode2 | 2-player mode |
+0x558 |
void* | gameMode3 | 4-player mode |
+0x55C |
void* | gameMode4 | Tournament mode |
+0x5D8 |
bool | p1_active | Player 1 active |
+0x5E8 |
int | p1_current_time | P1 time/score |
+0x5EC |
int | p1_extra_time | P1 bonus time |
+0x60C |
int | p1_race_index | P1 race slot |
+0x678 |
bool | p2_active | Player 2 active |
+0x688 |
int | p2_current_time | P2 time/score |
+0x84C |
float | mouseSensitivity | 0.0–1.0 |
+0x850 |
bool | mirrorMode | Tournament mirror |
+0x851–+0x868 |
bool[24] | unlock_flags | Race/arena unlocks |
+0x86C |
uint8[0x50] | bestTimes | Per-level best times |
+0x8BC |
uint8[0x50] | medals | Per-level medals |
+0x914 |
int | playCount | Total launches |
+0xB28 |
DWORD | p2Controller1 | DI device index |
+0xB2C |
DWORD | p2Controller2 | DI device index |
+0xB30 |
DWORD | p2Controller3 | DI device index |
+0xB34 |
DWORD | p2Controller4 | DI device index |
Document Info
- Generated from: Ghidra 12.0 decompilation + GhidraMCP REST API
- Game version: Hamsterball.exe (PE32, i386)
- Total functions in binary: ~3,800
- Functions documented: 120+
- Decompilation sources:
analysis/ghidra/decompilations/ - Struct headers:
analysis/ghidra/structs/*.h - Last updated: 2026-06-13
Cross-References
| Topic | Document |
|---|---|
| Arena scoring internals | docs/ARENA_SCORING.md |
| Arena hazards | docs/ARENA_HAZARD_SYSTEM.md |
| ball object deep dive | docs/BALL_OBJECT.md |
| App singleton | docs/APP_OBJECT.md |
| Scene system | docs/SCENE_SYSTEM_DECOMP.md |
| scene object modding | docs/SCENE_OBJECT_MODDING.md |
| Collision events | docs/COLLISION_EVENT_SYSTEM.md |
| input system | docs/DIRECTINPUT_SYSTEM.md |
| Save/registry | docs/SAVE_REGISTRY_SYSTEM.md |
| rendering pipeline | docs/RENDERING_PIPELINE.md |
| camera system | docs/CAMERA_SYSTEM.md |
| 8-ball AI | docs/8BALL_AI_SYSTEM.md |
| UI menus | docs/UI_MENU_SYSTEM.md |
| meshworld format | docs/MESHWORLD_FORMAT.md |
| Key decompilations | docs/KEY_DECOMPILATIONS.md |
| ArenaBoard system | docs/RUMBLEBOARD_SYSTEM.md |
| Game state lifecycle | docs/GAME_STATE_RACE_LIFECYCLE.md |
All offsets verified against raw Ghidra decompiled C. For questions, check the decompilation files in analysis/ghidra/decompilations/.
🔗 Related Documents
Mods Catalog
Hamsterball Mods Catalog
| Mod | Description | Files |
|---|---|---|
| universal-ref-loader | v3: Loads any ref type into any level via vtable[33] hook. JIT mesh injection from disk, clone-on-return for static-mesh objects, difficulty gate bypass, board slot safety checks | universal-ref-loader/ |
| global-lifters | Spawns Up Race lifters (Rotators) on any level with a hotkey | global-lifters/ |
| global-drawbridge | Spawns Tower Race drawbridges on any level with a hotkey | global-drawbridge/ |
| global-trapdoor | Spawns Tower Race trapdoors on any level with a hotkey | global-trapdoor/ |
| global-neon | Spawns all 6 neon race objects on any level + neon lighting mode (dark scene with ball-emitted point light). Toggle via NeonLighting/NeonAmbient symbols | global-neon/ |
| global-expert | Spawns all 6 expert race objects (Bonk/Hammer, Fan, Sawblade, Bridge, Judge, Bell) on any level with a hotkey | global-expert/ |
| global-bonk | Spawns Bonk the Hammer from Expert Race on any level — standalone, constructor loads its own mesh internally | global-bonk/ |
| wall-bumpers | All walls act as pinball bumpers — velocity-reversal detection amplifies wall bounces. F8 toggle, F9 force cycle | wall_bumpers/ |
| ball-break | Press X to shatter your ball and respawn at nearest checkpoint. Calls the game's own Ball_Shatter function | ball_break/ |
| half-size-all | Shrinks player ball to half size by inlining Ball_Shrink's physics fields (radius=13.0, physics_scale=2.5). No sound, player index 0 only | half_size_all/ |
| raptisoft-live-log | Passive logger — taps Raptisoft's hidden in-memory status tracking system (App+0x208/0x20C/0x210) and writes live updates to live_status.txt. No gameplay changes | raptisoft_live_log/ |
| level-colors | Changes per-level base colors (timer oval, timer text, race selection menu text) from a colors.txt config file. Edit at runtime | level_colors/ |
| universal-safespots | Adds SAFESPOT(*) universal checkpoint — always accepted as respawn candidate regardless of active SAFESWITCH filter. Competes on distance with lettered SAFESPOTs | universal-safespots/ |
| 8ball-spawn | Press B to spawn an 8-ball in front of the player's hamster ball. Uses mesh index 9 (8Ball), spawns as physics debris (player_index=-1) | 8ball_spawn/ |
| 8ball-goal-fix | Prevents crash when 8-ball (BadBall) touches N:GOAL or E:ACTION(SCORE) triggers. Adds player_index<0 guard (same pattern Raptisoft used for E:LIMIT) to 3 patch points in DispatchCollisionEvents | 8ball_goal_fix/ |
🔗 Related Documents
Neon Race Objects
Neon Race Objects: Complete Reverse Engineering
Overview
Neon Race (Level7) contains 6 unique object types that can be spawned globally:
- NEONPLATFORM — Disappearing neon floor
- DFLOOR1-4 — Four different disappearing floor sections
- TRODE — Neon electrode/tube objects
Factory Function
Scene_SetupLevelDark @ 0x416270 (vtable[0x48] of Neon Board vtable @ 0x4D1DF0)
This function:
- Loads main level mesh
Levels\LevelDark→ stores atBoard+0x8AC - Creates CollisionLevel from the main mesh →
Board+0x8B0 - Calls
Board_NeonRace_ctor(0x424440) to init the Board - Calls vtable[0x80] (0x416910) = CreateNeonObjects factory
Mesh Loading (Board_NeonRace_ctor @ 0x424440)
All 6 meshes are loaded into Board offsets:
| Board Offset | Mesh Path | String Address |
|---|---|---|
| +0x4374 | Levels\LevelDark-NeonPlatform |
0x4D1D9C |
| +0x4378 | Levels\LevelDark-DFloor1 |
0x4D1D80 |
| +0x437C | Levels\LevelDark-DFloor2 |
0x4D1D64 |
| +0x4380 | Levels\LevelDark-DFloor3 |
0x4D1D48 |
| +0x4384 | Levels\LevelDark-DFloor4 |
0x4D1D2C |
| +0x4388 | Levels\LevelDark-Trode |
0x4D1D14 |
These meshes are NOT pre-loaded at App level. They are only loaded by Board_NeonRace_ctor. For global spawning, they must be loaded via MeshWorld_ctor (0x461510).
Object Creation (CreateNeonObjects factory @ 0x416910)
The factory iterates the level's MESHWORLD object list and matches object names via strnicmp:
NEONPLATFORM (string at 0x4D00F0, length 12)
alloc = operator_new(0x10EC)
Stands_CtorRotator(alloc, Board, pos_X, pos_Y, pos_Z, mesh=Board+0x4374)
AthenaList_Append(Board+0x2578, obj)
sub-object = [obj+0x10D4] ; CollisionLevel (created internally)
DFLOOR1-4 (strings at 0x4D00E8/E0/D8/D0, length 7)
alloc = operator_new(0x1104)
DFloor_ctor(alloc, Board, pos_X, pos_Y, pos_Z, mesh=Board+0x4378/7C/80/84)
AthenaList_Append(Board+0x2578, obj)
sub-object = [obj+0x10E8] ; CollisionLevel (created internally)
TRODE (string at 0x4D00C8, length 5)
alloc = operator_new(0x1104)
DFloor_ctor(alloc, Board, pos_X, pos_Y, pos_Z, mesh=Board+0x4388)
AthenaList_Append(Board+0x2578, obj)
sub-object = [obj+0x10E8] ; CollisionLevel (created internally)
Constructors
Stands_CtorRotator (NEONPLATFORM) @ 0x43E110
- Calling convention:
ecx=this, ret 0x14 (5 stack params) - Params: Board, pos_X, pos_Y, pos_Z, mesh
- Vtable: 0x4D5A10
- Internal: Calls Stands_ctor(0x462850) with mesh as source → creates CollisionLevel at obj+0x10D4 via CollisionLevel_ctorWithLevel(0x465080)
- Position stored at: obj+0x10D8/X/DC/E0
DFloor/Trode ctor @ 0x43E450
- Calling convention:
ecx=this, ret 0x14 (5 stack params) - Params: Board, pos_X, pos_Y, pos_Z, mesh
- Vtable: 0x4D5A70
- Internal: Calls Stands_ctor(0x462850) with mesh as source → creates CollisionLevel at obj+0x10E8 via CollisionLevel_ctorWithLevel(0x465080)
- Position stored at: obj+0x10D0/D4/D8
- Board stored at: obj+0x10E4
Collision System
Physical Collision
Both constructors create a CollisionLevel sub-object internally via CollisionLevel_ctorWithLevel (0x465080). This gives the object its own spatial collision tree built from the mesh geometry. The ball will physically collide with the geometry through the generic spatial tree collision system.
Neon-Specific Collision Events (TowerCollisionEvents @ 0x416CA0)
The Neon Board vtable has a custom collision handler at vtable[0x74] = 0x416CA0. This handles:
| Event String | Address | Action |
|---|---|---|
N:NEONPLATFORM |
0x4D0120 | Calls [obj+0x47C] then NeonPlatform_Activate (0x437300) |
E:ZOOP |
0x4D0118 | Sets obj+0x7F0 = 100 (timer), creates 3D sound at ball position |
E:LIGHTSOFF |
0x4D010C | Sets obj+0x7B4 = 100, calls vtable[4], removes object from list |
E:LIGHTSON |
0x4D0100 | Sets obj+0x7B8, calls vtable[4], decrements Board+0x4390 |
On non-Neon levels: These events will NOT fire because the level's collision handler is different. Objects will have physical collision but no special behaviors (lights, zoop, disappear).
Finally calls DispatchCollisionEvents (0x40C5D0) at the end.
Global Spawn Requirements
- Load meshes via
MeshWorld_ctor(0x461510)into Board+0x4374..+0x4388 (cached, load once) - No AthenaList init needed — Board+0x2578 is initialized by Board_ctor on all levels
- No mesh+0x878 fix needed — Neon constructors call Stands_ctor directly, which does NOT read mesh+0x878 (unlike Glass_Level_ctor for Drawbridge)
- Constructor call:
push mesh; sub esp,0xC; fill pos; push Board; mov ecx,alloc; call ctor; push obj; Append(Board+0x2578)
Addresses Summary
| Function | Address | Ret |
|---|---|---|
| MeshWorld_ctor | 0x461510 | 8 |
| operator_new | 0x4BA57B | cdecl |
| Stands_CtorRotator (NEONPLATFORM) | 0x43E110 | 0x14 |
| DFloor/Trode ctor | 0x43E450 | 0x14 |
| AthenaList_Append | 0x453810 | 4 |
| CreateNeonObjects factory | 0x416910 | 0x10 |
| TowerCollisionEvents (Neon) | 0x416CA0 | 8 |
| Board_NeonRace_ctor | 0x424440 | 4 |
| Scene_SetupLevelDark | 0x416270 | — |
🔗 Related Documents
No-Pause Mod v2
No-Pause Mod v2
Prevents the pause menu from appearing via any input method — ESC key, right-click, or Win32 message pump. The game continues running normally with no pause overlay, no physics freeze, and no camera stop.
Why v1 Didn't Work
v1 only patched one of three code paths that trigger pause. Pressing ESC still paused the game through the Win32 message pump path, and right-clicking paused through the mouse event handler path.
The Three Pause Paths
Scene_CreateGameOverMenu (0x40a920) creates the pause overlay and sets scene+0x874 = 1 (pause flag). When this flag is set, GameUpdate (0x469cf0) skips calling Scene_Update on the scene — freezing all physics and game logic.
There are three independent code paths that call Scene_CreateGameOverMenu:
Path 1: DirectInput ESC Poll (Scene_Update)
Scene_Update (0x419c00)
→ Input_CheckKeyCombo(app, 2) ; checks ESC via DirectInput
→ if pressed: Scene_CreateGameOverMenu(scene, 1)
Gating conditions: game state not in {3,4}, scene+0x220 == 0, demo timer inactive.
Path 2: Right-Click Mouse Handler (vtable[5])
App_OnMouseDown(param_3=1=right button)
→ UIWidget_HitTest → vtable[5] on Scene
→ thunk 0x4130A0 checks param_3==1, App+0x238, profile+0x95
→ if conditions met: Scene_CreateGameOverMenu
This thunk appears in 32 scene-object vtables — all scene types inherit this pause-on-right-click behavior.
Path 3: Win32 Message Pump ESC (vtable[8])
WndProc → key message dispatch → vtable[8] on Scene
→ thunk 0x40B400 checks param == 0x1B (VK_ESCAPE = 27)
→ if match: JMP Scene_CreateGameOverMenu (tail call)
This is a separate ESC detection path from Path 1. Path 1 uses DirectInput polling; Path 3 uses the Win32 message pump. Both fire on ESC press. This thunk also appears in 32 vtables.
The Patches
Three single-byte patches, one per path:
| Path | Address | Original | Patched | Effect |
|---|---|---|---|---|
| 1 — DirectInput ESC | 0x419d5b | 74 09 (JZ) |
EB 09 (JMP) |
Always skip pause creation in Scene_Update |
| 2 — Right-click | 0x4130b5 | 74 17 (JZ) |
EB 17 (JMP) |
Always skip pause in vtable[5] right-click thunk |
| 3 — Message pump ESC | 0x40b405 | 75 0D (JNZ) |
EB 0D (JMP) |
Always skip pause in vtable[8] message handler thunk |
All three convert conditional jumps to unconditional jumps, causing the pause creation code to always be skipped.
How Pause Works (for reference)
Scene_CreateGameOverMenu(0x40a920) creates aPauseMenuorPauseArenaMenuoverlay and setsscene+0x874 = 1GameUpdate(0x469cf0) iterates scene objects each frame. For each object, it checksobj[0x21d](byte at scene+0x874). When the flag is 1, it skips callingScene_Update(vtable[1]) — freezing all game logic- When the player clicks "RESUME" in the pause menu,
PauseMenu_HandleButtonClicksetsscene+0x874 = 0— unfreezing the game
Installation
- Extract the zip into your Hamsterball game folder (next to
Hamsterball.exe) - Run
install.bat - Launch the game
Uninstallation
Run uninstall.bat to restore the original bass.dll.
Technical Details
- Mod type: BASS.dll proxy (v3 lazy loader pattern)
- Patch type: Three single-byte patches — conditional → unconditional jumps
- Patch addresses: RVA 0x19d5b, 0x130b5, 0x0b405 (VA 0x419d5b, 0x4130b5, 0x40b405)
- Side effects: None — ESC and right-click are simply ignored during gameplay. No menu, no pause, no freeze.
🔗 Related Documents
Object Catalog for Modders
Hamsterball — Object Catalog for Modders
What This Document Is
A comprehensive catalog of game objects found in Hamsterball.exe through Ghidra decompilation. Each object includes its constructor address, approximate size, vtable, key fields (where known), and how it fits into the game's object hierarchy.
Objects already documented in depth elsewhere are referenced rather than duplicated.
Table of Contents
- Object Hierarchy Overview
- Core Singletons
- Base Classes (Inheritance Root)
- Player Objects
- Level / Scene System
- Level Objects — Mechanical
- Level Objects — Hazards
- Level Objects — Environment
- UI / Menu System
- Graphics / Render Objects
- Audio Objects
- Score / GameState Objects
- Utility / Container Objects
- Objects Not Yet Documented
- How to Instantiate Objects at Runtime
Object Hierarchy Overview
SceneObject (vtable 0x4D934C, size 0xD4)
└─ Gadget (vtable 0x4D9170, size 0x870)
├─ Board (vtable 0x4D0260, size ~0x4368)
│ └─ ArenaBoard (vtable 0x4D1358, size ~0x47D4)
│ └─ GameLevel (vtable varies, size ~0x4368)
│ ├─ Glass_Level
│ ├─ Spinner_Level
│ ├─ Gear_Level
│ └─ BoardLevel3
├─ Platform (vtable varies, size 0x10FC)
├─ Stands (vtable varies, size 0x10D0)
├─ Secret (vtable varies, size 0x10EC)
├─ Looper (vtable varies, size 0x1500)
├─ Gear / BigGear (vtable varies, size 0x1514)
├─ Rotator (vtable varies, size 0x1508)
├─ Pendulum (vtable varies, size 0x1504)
├─ Tipper (vtable varies, size 0x1104)
│ └─ TipperVisual (visual component)
├─ Bonk (vtable varies, size 0x1200)
├─ BreakBridge (vtable varies, size 0x1100)
├─ PopCylinder (vtable varies, size 0x10E8)
├─ Blockdawg (vtable varies, size 0x1154)
├─ Catapult (vtable varies, size 0x1108)
├─ Gluebie (vtable varies, size 0x110C)
└─ Bumper / SpeedCylinder / Spinner / Sawblade / MouseTrap
Ball (vtable 0x4CF3A0, size 0xC98) — NOT in Gadget hierarchy
└─ Ball_Split (vtable 0x4CF560)
Graphics (vtable 0x4D88A0, size ~0x540+)
MeshWorld (vtable 0x4D9CDC, size 0x488)
Level (vtable 0x4D8FB0, size 0x10D4)
CollisionLevel (vtable varies, size varies)
Scene (inherits Gadget, vtable 0x4D0260)
UI / Menu hierarchy:
SimpleMenu
├─ MainMenu
├─ OptionsMenu
├─ PracticeMenu
├─ ConfirmMenu
├─ GameSelectionScreen
├─ HighScoreEntry
├─ RaceResultsMenu
└─ SaveTourneyDialog
UIListItem
RaceResultPopup
OkayDialog
QuitDialog
RegisterDialog
ArenaScoreParticle
Sprite
ScoreObject
Core Singletons
App
- Address:
0x004FD680(global pointerg_App) - Type:
App*— singleton holding all subsystem pointers - Size: ~0xA00+ bytes
- Vtable:
0x004CE400 - Constructor:
App_ctor(0x46DC40) - Deep documentation: See
APP_OBJECT.md - Key subsystems:
graphics(+0x174),audioSystem(+0x17C),musicHandle(+0x534),registryKey(+0x54),gameMode1-5(+0x550 to +0x55C)
Graphics
- Constructor:
Graphics_ctor(0x4542C0) - Vtable:
0x4D88A0 - Size: ~0x540+ bytes
- Deep documentation: See
D3D8_RENDERING_PIPELINE.md - Key fields: D3D device, texture cache, viewport, frustum, render mode
Base Classes
These are abstract base classes you don't typically instantiate directly, but they define the field layout for all derived objects.
SceneObject
- Constructor:
SceneObject_ctor(0x46B4F0) - Vtable:
0x4D934C(destructor),0x4D9170(scalar dtor) - Size: 0xD4 bytes (212 bytes)
- Fields:
+0x00vtable+0x08app_ptr+0x14visibility flag+0x18field_18+0x1Cfield_1c+0x20field_20+0x24field_24+0x28field_28+0x2Cbyte_2c+0x30field_30+0x34AthenaList (child objects)+0x44AthenaList (another child list)+0x54field_54+0x58field_58+0x5Cfield_5c+0x60field_60+0x64field_64+0x68field_68+0x6Cfield_6c+0x70field_70+0x74field_74+0x78field_78+0x7Cfield_7c+0x80field_80+0x84field_84+0x88field_88+0x8Cfield_8c+0x90field_90+0x94base_scale matrix (4x4)+0xA8rotation matrix (4x4)+0xBCworld transform matrix (4x4)+0xD0field_d0
- Deep documentation: See
SCENE_STRUCT.md
Gadget
- Constructor:
Gadget_ctor(0x4690F0) - Vtable:
0x4D9170(inherits from SceneObject) - Size: 0x870 bytes
- Parent of: Board, Platform, Stands, Secret, all mechanical/hazard objects
- Key fields:
+0x00vtable →0x4D9170+0x014app_ptr (App*)+0x018second vtable →0x004CF584+0x034AthenaList (children)+0x44CAthenaList (siblings)+0x868name string (e.g.,"Generic Gadget")
- Deep documentation: See
SCENE_STRUCT.md
GameObject
- Vtable:
0x4CF314(used as initial vtable beforeBallis fully constructed) - Size: ~0x810+ bytes
- Parent of: Ball (Ball is NOT in Gadget hierarchy — it's a separate branch)
- Used by:
Ball_ctor(0x40AFE0) callsGameObject_ctorfirst
Player Objects
Ball
- Constructor:
Ball_ctor(0x40AFE0),Ball_ctor2(0x4039E0) - Vtable:
0x4CF3A0(9 method pointers) - Size: 0xC98 bytes (3224 bytes)
- Deep documentation: See
BALL_OBJECT_MODDING.md - Key fields:
+0x00vtable+0x08collision_result+0x0Cstring_timer+0x10app_state+0x14scene ptr+0x18player_index (-1 = AI)+0x164pos_x/y/z+0x170vel_x/y/z+0x188max_speed (5000.0f)+0x18Cspeed_scale (1.0f)+0x1A8gravity vector+0x284radius (27.0f)+0x2DClgp_x/y/z+0x768cam_active+0xC4Cis_shrunk flag (odd race E:SHRINK/E:GROW)+0xC88world transform matrix (4x4)
- Methods (vtable slots):
[0x10]Ball_Update (0x405E00) — main tick[0x14]Ball_ApplyForceWithMultipliers (0x401590)[0x18]Ball_Render (0x4027F0)[0x1C]Ball_CollisionHandler (0x402DE0)[0x20]Ball_InputHandler (0x402A70)[0x24]Ball_PhysicsUpdate (0x405100)[0x28]Ball_SpawnHandler (0x402C10)[0x2C]Ball_AIUpdate (0x408390)[0x30]Ball_SpecialUpdate (0x409480)
Ball_Split
- Constructor:
Ball_Split_ctor(0x408D10) - Vtable:
0x4CF560 - Description: Split-ball variant (multiplayer / tournament split-screen)
Level / Scene System
Scene / Board
- Constructor:
Board_ctor(0x419020),Sceneinherits fromGadget - Vtable:
0x4D0260 - Size: ~0x4368 bytes (Board), Scene is the same structure
- Deep documentation: See
SCENE_STRUCT.mdandSCENE_OBJECT_MODDING.md - Key fields:
+0x000Gadget base (0x870 bytes)+0x221ArenaBoard timer data+0x335Ball list (AthenaList)+0x43BEffect list (AthenaList)+0x361CBall pointer (first player ball)+0x29B0Gravity parameter+0x29C0Camera orbit distance+0x3AACCamera Vec3+Matrix pairs (5 sets)
Level
- Constructor:
Level_ctor(0x461740) - Vtable:
0x4D8FB0 - Size: 0x10D4 bytes
- Description: Container for level geometry, objects, and state. Inherits from Gadget.
- Fields: 4 AthenaLists, Timer, LevelState
GameLevel
- Constructor:
GameLevel_ctor(0x4351F0) - Description: Wraps Level with game-specific logic (Stands init, clone, sound channel)
MeshWorld
- Constructor:
MeshWorld_ctor(0x4706E0, 0x46F3D0) - Vtable:
0x4D9CDC - Size: 0x488 bytes (small ctor), larger version handles strips
- Description: Level geometry parser and container. Loads
.meshworldfiles. - Deep documentation: See
MESHWORLD_FORMAT.md
CollisionLevel
- Constructor:
CollisionLevel_ctor(0x4652E0) - Description: Collision-only level data (
.meshcollisionformat)
Level Objects — Mechanical
Platform
- Constructor:
Platform_ctor(0x437040) - Size: 0x10FC bytes
- Description: Static or animated platform. Most common level object.
- Created by:
CreatePlatformOrStandsfactory
Stands
- Constructor:
Stands_ctor(0x462850) - Size: 0x10D0 bytes
- Description: Audience / stadium stands geometry
Looper
- Constructor:
Looper_ctor(0x435800) - Size: 0x1500 bytes
- Description: Loop-de-loop ramp structure
Gear / BigGear
- Constructor:
Gear_ctor(0x437590) - Size: 0x1514 bytes
- Description: Rotating gear obstacles. Uses
N:BOUNCE(bounce off gear surface),N:ONGEAR(attach to gear rotation viaCatapult_AddObjectConditional), andN:ONROTATOR(attach viaRotator_AddBall). Found inLevelImpossible-Gear.MESHWORLD(8 N:BOUNCE triggers). Gear rotation handled byCatapult_Update(0x43E600) which applies rotation matrix to tracked balls each frame. See Rotator System for mechanics.
Rotator
- Constructor:
Rotator_ctor(0x435940) - Size: 0x1508 bytes
- Description: Rotating platform / arm. Uses
N:ONROTATORevent to attach balls viaRotator_AddBall(0x43B6F0). Ball position and velocity rotated each frame byCatapult_Update(0x43E600). 10-frame grace period after ball leaves surface before release.
Pendulum
- Constructor:
Pendulum_ctor(0x437700) - Size: 0x1504 bytes
- Description: Swinging pendulum obstacle
Tipper
- Constructor:
Tipper_ctor(0x437960) - Size: 0x1104 bytes
- Description: Tipping platform that tilts when ball rolls on it
- Visual component:
TipperVisual_ctor(0x4661A0)
Catapult
- Constructor:
Catapult_ctor(0x437E10) - Size: 0x1108 bytes
- Description: Spring-loaded launcher platform
BreakBridge
- Constructor:
BreakBridge_ctor(0x436D70) - Size: 0x1100 bytes
- Description: Bridge that breaks when ball crosses
PopCylinder
- Constructor:
PopCylinder_ctor(0x436EE0) - Size: 0x10E8 bytes
- Description: Cylinder that pops up from ground
Level Objects — Hazards
Bonk (Hammer)
- Constructor:
Bonk_ctor(0x438850) - Size: 0x1200 bytes
- Description: Giant swinging hammer obstacle
- Popup feedback:
CreateBonkPopup(0x438B30)
Blockdawg
- Constructor:
Blockdawg_ctor(0x43C310) - Size: 0x1154 bytes
- Description: Block creature / moving block hazard
Gluebie
- Constructor:
Gluebie_ctor(0x437CB0) - Size: 0x110C bytes
- Description: Glue blob that slows the ball
Spinner
- Factory:
HandleArenaCollisionEvents(0x412850) - Description: Spinning blade / propeller hazard
Sawblade
- Factory:
CreateExpertLevelObjects(0x40E250) - Description: Rotating saw blade hazard
MouseTrap
- Factory:
CreateMouseTrap(0x40BF50) - Description: Snap-shut trap hazard
Bumper
- Factory:
CreateBumper(0x40FA20),CreateBumper2(0x413CE0) - Description: Pinball-style bumper that knocks ball away
SpeedCylinder
- Factory:
CreateUpLevelObjects(0x4117B0) - Description: Cylinder that accelerates or decelerates ball
NoDizzy
- Factory:
DispatchCollisionEvents(0x40C5D0) - Description: Power-up that prevents dizzy state
Level Objects — Environment
Secret
- Constructor:
Secret_ctor(0x43DFB0) - Size: 0x10EC bytes
- Description: Hidden collectible / secret area object
- Factory:
CreateSecretObjects(0x40BAA0)
Glass_Level
- Constructor:
Glass_Level_ctor(0x4384A0) - Description: Glass-themed level variant (transparent bridges, walls)
Spinner_Level
- Constructor:
Spinner_Level_ctor(0x4396F0) - Description: Level variant with spinner obstacles
Gear_Level
- Constructor:
Gear_Level_ctor(0x43A150) - Description: Level variant with gear obstacles
BoardLevel3
- Constructor:
LevelBoard_Dizzy_ctor(0x41D060) - Description: Tournament level 3 board state
UI / Menu System
SimpleMenu (base)
- Constructor:
SimpleMenu_ctor(0x448F20) - Description: Base class for all menu screens. Handles item list, up/down scrollers, selection.
MainMenu
- Constructor:
MainMenu_ctor(0x42DE50) - Description: Title screen — LET'S PLAY, HIGH SCORES, OPTIONS, CREDITS, EXIT
OptionsMenu
- Constructor:
OptionsMenu_ctor(0x442CE0) - Description: Settings screen — Resolution, Fullscreen, Color depth, Safe Mode, Volume, Key Remap, Mouse, Pause
PracticeMenu
- Constructor:
PracticeMenu_ctor(0x42EA30) - Description: "CHOOSE A PRACTICE RACE!" — 14 race items with thumbnails
ConfirmMenu
- Constructor:
ConfirmMenu_ctor(0x42B190) - Description: BACK / BACK2TOURNAMENT / DONE confirmation dialog
GameSelectionScreen
- Constructor:
GameSelectionScreen_ctor(0x42E060) - Description: Difficulty selector for tournament mode
HighScoreEntry
- Constructor:
HighScoreEntry_ctor(0x42B470) - Description: Name input + score display after race
RaceResultsMenu
- Constructor:
RaceResultsMenu_ctor(0x44CB10) - Description: Post-race screen with title, subtitle, player entries, timer
RaceResults
- Constructor:
RaceResults_ctor(0x44B8A0) - Description: Race result data object (timers, congratulatory text, score thresholds)
RaceResultPopup
- Constructor:
RaceResultPopup_ctor(0x44C260) - Description: Small popup showing rank + "TIME'S UP!" / "OUT OF TIME!" text
SaveTourneyDialog
- Constructor:
SaveTourneyDialog_ctor(0x44FD60) - Description: "Save tournament progress?" dialog
RegisterDialog
- Constructor:
RegisterDialog_ctor(0x4476B0) - Description: Purchase / registration reminder dialog
OkayDialog
- Constructor:
OkayDialog_ctor(0x440E70) - Description: Simple "OKAY!" button dialog with caption
QuitDialog
- Constructor:
QuitDialog_ctor(0x443E30) - Description: "YES" / "NO" quit confirmation dialog
UIListItem
- Constructor:
UIListItem_ctor(0x4490A0) - Size: 0x444 bytes
- Description: Single selectable item in a menu list. Contains Vec3 + AthenaList.
Graphics / Render Objects
RenderContext
- Constructor:
RenderContext_Init(0x457FA0) - Size: 0x50 bytes
- Vtable:
0x4D8E68 - Description: Per-object render state (material, transform, visibility)
MeshBuffer
- Factory:
CreateMeshBuffer(0x458970) - Description: D3D8 vertex/index buffer wrapper
Texture
- Loader:
Graphics_LoadTexture(0x4542C0) - Constructor: implicit via D3D8
CreateTexture - Description: GPU texture object. Loaded from PNG/BMP files.
Sprite
- Constructor:
Sprite_ctor(0x45D0C0) - Description: 2D screen-space sprite with texture + RenderContext + material defaults
MeshNode
- Constructor:
MeshNode_ctor(0x471C20) - Vtable:
0x4D9C48 - Description: Scene graph node that holds a loaded mesh file
MeshArchive
- Constructor:
MeshArchive_ctor(0x478E70) - Description: Mesh resource pool. Manages D3D resources, mesh groups, position tracking.
Audio Objects
BASS Music
- Handle:
App.musicHandle(+0x534) —music\music.mo3 - Channels:
musicChannel1(+0x538),musicChannel2(+0x53C) - Loader:
LoadMusicFile(0x46A020) - Deep documentation: See
AUDIO_SYSTEM.md
SFX / 3D Sound
- Handle:
Ball.sound_3d_handle(+0x700) - System: DirectSound8 + BASS_SampleLoad
- Deep documentation: See
AUDIO_SYSTEM_SFX.md
Score / GameState Objects
ArenaScoreParticle
- Constructor:
ArenaScoreParticle_ctor(0x44AD50) - Description: Floating score popup in Rumble mode. Difficulty scale: 0.02/0.03/0.04.
ScoreObject
- Description: SceneObject subclass. Despite the name, its
SetScoremethod (now renamedRotator_AddBallat 0x43B6F0) does NOT set a score — it registers balls on a rotator's tracking list for physical rotation. TheScoreObject_ctor(0x44BE80) creates a SceneObject with vtablePTR_RaceGoalReached_Render(0x4D6C70), used for race goal rendering. - Used by:
Rotator_AddBallcalled fromImpossibleCollisionEvents(N:ONROTATOR),ToobCollisionEvents(N:SPINNY),DizzyArenaCollisionEvents(N:SWIRL)
ArenaBoard
- Constructor:
ArenaBoard_ctor(0x4217B0) - Size: ~0x47D4 bytes
- Description: Rumble mode game board. Inherits from Board. Timer-based scoring.
- Embedded timer:
ToggleTimer_Init/ToggleTimer_Cleanup
Utility / Container Objects
AthenaList
- Function:
AthenaList_Append(0x453780) - Description: Dynamic array/list container used throughout the engine. Resizable, append-only.
- Used by: Almost every object for child/sibling tracking.
AthenaHashTable
- Constructor:
AthenaHashTable_ctor(0x472C20) - Vtable:
0x4CF584 - Description: Hash table for string→object lookups (e.g., mesh names)
Vec3List
- Description: Array of
Vec3structures. Used for position/path data.
Timer
- Function:
Timer_Init(various) - Description: Countdown / animation timer used by objects for state transitions.
Objects Not Yet Documented
These objects have constructors or factories in the binary but lack deep reverse-engineering documentation:
| Object | Constructor/Factory | Notes |
|---|---|---|
| E:LIMIT boundary | NeonCollisionEvents (0x410D00) |
Invisible race boundary |
| E:NODIZZY dizzy immunity zone | DispatchCollisionEvents (0x40C5D0) |
Prevents dizzy state |
| E:JUMP trigger | (inline in collision) | Launch pad trigger object |
| SAFESPOT | (inline in factory) | Safe zone / checkpoint |
| CAMERALOOKAT | CameraLookAt (0x413280) |
Camera target marker |
| FLAG checkpoints | (inline in factory) | Race checkpoint objects |
| BRIDGE (tipper variant) | CreateLevelObjects (0x4121D0) |
Breakable bridge sub-type |
| TIPPER (via factory) | CreateLevelObjects (0x4121D0) |
Tipping platform sub-type |
| BBRIDGE1/2 | CreateLevelObjects (0x4121D0) |
Bridge variants |
| CATAPULT (via factory) | CreateLevelObjects (0x4121D0) |
Catapult sub-type |
| GLUEBIE (via factory) | CreateLevelObjects (0x4121D0) |
Glue blob sub-type |
| BLOCKDAWG1/2 | CreateLevelObjects (0x4121D0) |
Block creature variants |
| POPCYLINDER (via factory) | CreateLevelObjects (0x4121D0) |
Pop-up cylinder sub-type |
How to Instantiate Objects at Runtime
All level objects are created through the object factory system in Scene/Level. The typical pattern is:
// Inside Scene::LoadLevel or Board::Init
void* obj = operator_new(0x1104); // Allocate Tipper size
Tipper_ctor(obj, scene_ptr, param2); // Call constructor
AthenaList_Append(scene->object_list, obj); // Add to scene
Factory Pattern
For MESHWORLD-defined objects, the factory is CreateLevelObjects (0x4121D0):
// Pseudocode from decompilation
if (strcmp(type, "TIPPER") == 0) {
obj = operator_new(0x1104);
Tipper_ctor(obj, scene, params);
}
else if (strcmp(type, "BONK") == 0) {
obj = operator_new(0x1200);
Bonk_ctor(obj, scene, params);
}
// ... etc
Modding Approach
To add custom objects:
- Hook
CreateLevelObjects(0x4121D0) - Add your own type string check
- Allocate your object size
- Call your custom constructor
- Append to the scene's object list
See LEVEL_OBJECT_FACTORY.md for the full factory breakdown.
Document Revision
- Sources: Ghidra decompilations (
analysis/ghidra/decompilations/),FUNCTION_MAP.md,renames_backup.json, struct headers (analysis/ghidra/structs/) - Total objects cataloged: 57 constructors + 14 factories + 6 base classes + 3 singletons
- Coverage: All major gameplay objects, UI screens, graphics/audio subsystems
- Next additions: Deeper field analysis for undocumented mechanical objects (TipperVisual, BreakBridge, PopCylinder internals), factory parameter formats
For memory layouts of Ball, App, Scene, and Gadget, see the individual struct documents in analysis/ghidra/structs/ and the markdown docs referenced above.
🔗 Related Documents
Odd Race
Odd Race — Ball Shrink/Grow Mechanic
Overview
The Odd Race (menu position 9) features a unique mechanic where the player's ball shrinks to half size when entering a shrink zone, then grows back when reaching a grow zone. This is used in the pipe maze section of the level.
Level File Mapping
| Menu Name | Board Constructor | MESHWORLD File | Scene Setup Function |
|---|---|---|---|
| Odd Race | LevelBoard_Odd_ctor @ 0x0041ED80 |
Level6.MESHWORLD |
Scene_SetupLevel6 @ 0x0040EA90 |
Note: The file is named
Level6.MESHWORLDbut the board isLevelBoard_Odd. The numbering schemes don't match — file numbers are internal, board numbers are menu order. The Odd board's vtable (0x4D0BC0, slot+0x48) points toScene_SetupLevel6which loadslevels\level6.
The Shrink/Grow Sequence
Three collision events drive the mechanic
All three are event-name-triggered (E: prefix) collision geometry inside Level6.MESHWORLD:
| Event Name | Handler Code | What It Does |
|---|---|---|
E:SHRINK |
0x0040EF80 in OddCollisionEvents |
Shrinks the ball |
E:GROWSOUND |
0x0040F04E in OddCollisionEvents |
Plays grow sound (100-frame cooldown) |
E:GROW |
0x0040F09E in OddCollisionEvents |
Restores ball to full size |
E:SHRINK — Ball_Shrink (0x00402200)
void __fastcall Ball_Shrink(int ball) {
ball[0xC4C] = 1; // is_shrunk flag = ON (ball is shrunk)
ball[0x284] = 0x41500000; // radius = 13.0 (was 26.0)
ball[0x188] = 0x40200000; // physics_scale = 2.5 (was 5.0)
Sound_Play3D(board->sound_device->fall_sound, ball->pos);
}
Then the Odd handler:
- Looks up
SHRINKCENTERrefpoint position from the hash table - Teleports the ball there:
(-285.2, -2210.5, -1068.8) - Sets downward trajectory: velocity =
(0.0, -1.0, 0.0)(Y=-1.0, pushes ball down)
E:GROWSOUND — Sound Only
if (ball[0x1FE] == 0) { // cooldown counter
Sound_PlayChannel(board->sound_device->grow_sound);
}
ball[0x1FE] = 100; // 100-frame cooldown (4 seconds at 25fps)
E:GROW — Ball_Grow (0x00402270)
void __fastcall Ball_Grow(int ball) {
ball[0xC4C] = 0; // is_shrunk flag = OFF (ball is normal)
ball[0x284] = 0x41D00000; // radius = 26.0 (restored)
ball[0x188] = 0x40A00000; // physics_scale = 5.0 (restored)
}
Field Changes Summary
| Ball Offset | Field Name | Normal Value | Shrunk Value | Ratio |
|---|---|---|---|---|
+0xC4C |
is_shrunk (byte) |
0 |
1 |
— |
+0x284 |
radius (float) |
26.0 |
13.0 |
50% |
+0x188 |
physics_scale (float) |
5.0 |
2.5 |
50% |
Physics Effects of Shrunk State
The is_shrunk flag (ball+0xC4C) gates several behaviors in Ball_Update (0x00405E00):
1. Speed Penalty (line ~858)
When the ball exceeds max speed and is shrunk:
if (speed > max_speed) {
speed = max_speed;
if (ball[0xC4C] != 0) { // is shrunk
speed *= 0.25; // _DAT_004cf380 = 0.25 → 75% speed reduction
}
}
The shrunk ball moves at 25% of max speed — significantly slower than normal.
2. Camera Skip (line ~797)
When the ball is on a fast-moving surface and shrunk:
if (speed > 1.0 && ball[0xC4C] == 0) { // only when NOT shrunk
ball[0x2E9] = 1; // on_ramp flag
Scene_SetCamera(ball->board, ball, 1);
// ... viewport setup
}
Camera tracking is skipped when shrunk — prevents camera jerk during teleport.
3. Bumper Counter Skip (line ~839)
if (bumper_count != 0 && ball[0xC4C] == 0 && speed >= threshold) {
bumper_count++; // only increments when NOT shrunk
}
4. Ball_FallUpdate (vtable[65], 0x00408830)
When is_falling=1, the game calls Ball_FallUpdate instead of normal physics. This function:
- Copies
ball+0x188(physics_scale) tophysics_obj+0xC70 - Uses
ball+0x284(radius) for collision detection viaSpatialTree - Decays
ball+0xC60(a float, starts at 1.0, decremented by 0.02/frame) — when it hits 0, setsball+0x2E8=1(event flag, likely triggers respawn/end)
Rendering Effects
Ball_Render (0x00402DE0)
The ball's visual scale is derived from ball+0x284 (radius):
// Mesh scale = radius * 0.037037 (1/27)
vtable[0x38](ball->radius * _DAT_004cf39c); // Gfx_Scale = 26.0/27 ≈ 0.963
// Shadow scale = radius * 0.028571 (1/35) * int_factor
Sprite_RenderQuad(..., ball->radius * _DAT_004cf398 * _DAT_004cf390, ...);
When shrunk: 13.0/27 ≈ 0.481 — the ball renders at half visual size.
Refpoint Locations (Level6.MESHWORLD)
| Refpoint | Position (X, Y, Z) | Purpose |
|---|---|---|
SHRINKCENTER |
(-285.2, -2210.5, -1068.8) |
Where ball teleports after shrinking |
JUMPPIPE1 |
(-848.5, -1770.7, -645.6) |
First jump pipe exit (Y velocity = +16.0) |
JUMPPIPE2 |
(-566.3, -2113.6, -645.6) |
Second jump pipe exit (Y velocity = +16.0) |
PIPERANDOM1 |
(-379.8, -2620.7, -1825.9) |
Random pipe destination 1 |
PIPERANDOM2 |
(-268.2, -2598.9, -1805.9) |
Random pipe destination 2 |
Complete Odd Collision Event List
The Odd board's collision dispatch handler is at 0x0040ED30 (vtable[0x1D], +0x74).
| Event | Effect |
|---|---|
E:GRAVITY(TYPE) |
Changes gravity: NORMAL/X/Z |
N:JUMPFIRST |
Teleport to JUMPPIPE1, launch up (Y=+16.0) |
N:JUMPSECOND |
Teleport to JUMPPIPE2, launch up (Y=+16.0) |
E:SHRINK |
Shrink ball to 50% size, teleport to SHRINKCENTER |
E:GROWSOUND |
Play grow sound (100-frame cooldown) |
E:GROW |
Restore ball to full size |
E:DROPLIFT |
Play break sound on mesh at board+0x436C |
E:PIPERANDOM |
Random teleport to PIPERANDOM1 or PIPERANDOM2 |
E:LIMIT |
Qualify for limit gate (axis 0) |
E:LIMITX |
Qualify for limit gate (axis 1) |
E:LIMITZ |
Qualify for limit gate (axis 2) |
E:LIMITPIPE1 |
Qualify for limit (pipe 1 flag) |
E:LIMITPIPE2 |
Qualify for limit (pipe 2 flag) |
E:SWALLOW |
Set swallow flag (ball+0xBA = 1) |
Related Functions
| Function | Address | Role |
|---|---|---|
Ball_Shrink |
0x00402200 |
Shrinks ball (radius 26→13, physics 5→2.5) |
Ball_Grow |
0x00402270 |
Restores ball (radius 13→26, physics 2.5→5) |
Ball_FallUpdate |
0x00408830 |
Physics update when shrunk (vtable[65]) |
Ball_Update |
0x00405E00 |
Normal physics (checks is_falling flag) |
Ball_Render |
0x00402DE0 |
Visual scaling from radius |
OddCollisionEvents |
0x0040ED30 |
Event dispatch for Odd race |
LevelBoard_Odd_ctor |
0x0041ED80 |
Odd board constructor |
Scene_SetupLevel6 |
0x0040EA90 |
Loads levels\level6 MESHWORLD for Odd |
🔗 Related Documents
Options Menu
Hamsterball Options Menu: Adding New Sliders and Decimal-Display Values
Scope: Original Hamsterball.exe (PE32, i386, Athena engine, VS2003).
Method: Direct Ghidra decompilation and PE disassembly.
Last Updated: 2026-06-14
1. Overview
The options menu is implemented as a UIList-based menu (SimpleMenu subclass).
Each entry is a normal UIListItem with:
- A display label shown to the player (e.g.
"SOUND VOLUME:"). - A short ID code used internally to identify which entry is selected/acted on (e.g.
"SV","MV","TQ","MS").
The four existing slider-like items are:
| Label | ID | Staging value offset | Dirty flag | Applies to |
|---|---|---|---|---|
SOUND VOLUME: |
SV |
OptionsMenu + 0xD10 |
+0xD1C |
SoundDevice + 0x838 |
MUSIC VOLUME: |
MV |
OptionsMenu + 0xD48 |
+0xD54 |
MusicDevice + 0x8 |
TEXTURE QUALITY: |
TQ |
OptionsMenu + 0xD80 |
+0xD8C |
App + 0x174 graphics object, offset +0x184 |
MOUSE SENSITIVITY: |
MS |
OptionsMenu + 0xDF0 |
+0xDFC |
App + 0x84C |
Goal of this document: show how to add a new slider entry, make it respond to left/right input, clamp and store its value, apply it to a subsystem, persist it to the registry, and display a decimal numeric readout such as FOV: 0.7 instead of a plain bar.
2. Key Functions and Addresses
| Function | Address | Purpose |
|---|---|---|
OptionsMenu_ctor |
0x00442CE0 |
Builds the options list with UIList_AddItem calls. |
OptionsMenu_AdjustVolume |
0x00442680 |
Receives (id_code, delta) and updates the matching staging float. |
GraphicsOptionsMenu_Update |
0x00441E70 |
Applies staged values to real subsystems each frame. |
UIList_AddItem |
0x004497F0 |
Adds one menu entry. Signature: void __thiscall UIList_AddItem(void* this, char* display_text, char* id_code, ... int is_clickable). |
UIList_SetTextByName |
0x0044A8B0 |
Updates the display text of an existing item by its ID code. |
AthenaString_SprintfToBuffer |
0x004BAE43 |
printf-family formatter; supports %d, %s, and %f / %.1f. |
App_SaveAllConfig |
0x004284C0 |
Saves persistent settings to the registry on exit. |
App_Ctor / registry init |
0x46DC40 region |
Reads saved values back into App offsets at startup. |
3. How an Existing Slider Works
3.1 Value change
OptionsMenu_AdjustVolume (0x00442680) does the following for each known ID:
if (__stricmp(param_1, "SV") == 0) {
float new_val = (float)param_2 * 0.1f + *(float*)(this + 0xD10);
if (new_val > 1.0f) new_val = 1.0f;
if (new_val < 0.0f) new_val = 0.0f;
*(float*)(this + 0xD10) = new_val;
*(BYTE*)(this + 0xD1C) = 1; // dirty flag
}
Observed from disassembly:
- The step constant
0.1is the double at_DAT_004CF308(0x004CF308). - The upper clamp is
_DAT_004CF310=1.0fat0x004CF310. - The lower clamp is
_DAT_004CF368=0.0fat0x004CF368. param_2is the signed delta:-1for left,+1for right (actual input routing passes a scaled value; see section 8).
3.2 Value application
GraphicsOptionsMenu_Update (0x00441E70) reads each dirty flag and writes the staged float to the real subsystem. For example:
if (*(BYTE*)(this + 0xD1C)) {
*(float*)(sound_device + 0x838) = *(float*)(this + 0xD10);
*(BYTE*)(this + 0xD1C) = 0;
}
3.3 Value persistence
App_SaveAllConfig (0x004284C0) writes the active subsystem value to the registry. Mouse sensitivity, for instance, is saved as a DWORD named "MouseSensitivity" from App + 0x84C.
4. Adding a Brand-New Slider
This example adds a hypothetical FOV slider (field-of-view scaling). Pick whatever subsystem you actually want to control.
4.1 Reserve storage in the OptionsMenu struct
Choose unused space after the existing sliders. Example offsets:
struct OptionsMenu_extra {
// ... existing fields up to +0xDFC ...
float fov_staging; // +0xE10 (example — verify no overlap)
BYTE fov_dirty; // +0xE14
};
Always verify the chosen offsets are not already used by
OptionsMenu_RenderControls(0x0042E840) or byGraphicsOptionsMenu_Update(0x00441E70). Use Ghidra to check cross-references before patching.
4.2 Add the menu item
In OptionsMenu_ctor (0x00442CE0), add another UIList_AddItem call near the other sliders:
UIList_AddItem(this, "FOV:", "FOV",
/* same trailing args as a neighboring slider */,
/* param_8 = 1 if left/right should work on it */);
The ID code "FOV" is arbitrary but must be unique in this menu and must be uppercase if you want consistent __stricmp behavior.
4.3 Handle left/right changes
Patch OptionsMenu_AdjustVolume (0x00442680) with a new branch:
if (__stricmp(param_1, "FOV") == 0) {
float new_val = (float)param_2 * 0.1f + *(float*)(this + 0xE10);
if (new_val > 1.0f) new_val = 1.0f;
if (new_val < 0.0f) new_val = 0.0f;
*(float*)(this + 0xE10) = new_val;
*(BYTE*)(this + 0xE14) = 1;
}
4.4 Apply the value each frame
In GraphicsOptionsMenu_Update (0x00441E70), add:
if (*(BYTE*)(this + 0xE14)) {
float fov = *(float*)(this + 0xE10);
// example: store in App + 0x???? or pass to graphics/camera code
*(float*)(app_ptr + 0xYYY) = fov;
*(BYTE*)(this + 0xE14) = 0;
}
4.5 Read and write the registry
In App_SaveAllConfig (0x004284C0) add a save line:
RegKey_WriteDWORD(app + 0x54, "FieldOfView",
(DWORD)(*(float*)(app + 0xYYY) * 100.0f));
And add a matching read in the startup path (near where MouseSensitivity is read) to restore the value.
5. Displaying a Decimal Value
The existing sliders render only a proportional bar. They do not display numbers. To show a decimal number you must update the menu item's text each time the value changes.
5.1 Required functions
AthenaString_SprintfToBuffer(local_1024_buf, "%s: %.1f", label, value)— formats the string.UIList_SetTextByName(this, local_1024_buf, "FOV")— pushes the formatted text to the menu entry whose ID code is"FOV".
UIList_SetTextByName is at 0x0044A8B0. It frees the old text, allocates a copy, and updates the item. It is safe to call every time the value changes.
5.2 Patch OptionsMenu_AdjustVolume to update the label
After the new "FOV" branch, immediately refresh the text:
if (__stricmp(param_1, "FOV") == 0) {
float new_val = (float)param_2 * 0.1f + *(float*)(this + 0xE10);
if (new_val > 1.0f) new_val = 1.0f;
if (new_val < 0.0f) new_val = 0.0f;
*(float*)(this + 0xE10) = new_val;
*(BYTE*)(this + 0xE14) = 1;
char buf[1024];
AthenaString_SprintfToBuffer(buf, "FOV: %.1f", new_val);
UIList_SetTextByName(this, buf, "FOV");
}
This is the minimal, safe way to make the slider show a decimal readout.
5.3 Initialize the label in OptionsMenu_ctor
When the menu is first built, the text is still "FOV:". Set it to the current value as well:
char buf[1024];
float current_fov = *(float*)(this + 0xE10);
AthenaString_SprintfToBuffer(buf, "FOV: %.1f", current_fov);
UIList_SetTextByName(this, buf, "FOV");
Place this right after the existing UIList_SetTextByName block (lines 100–112 of OptionsMenu_ctor in the decompilation).
5.4 Decimal precision
Use the normal printf precision specifiers:
| Format string | Output example | Use case |
|---|---|---|
"%.0f" |
FOV: 7 |
Whole numbers |
"%.1f" |
FOV: 0.7 |
One decimal place |
"%.2f" |
FOV: 0.75 |
Two decimal places |
The engine uses the standard CRT vsprintf (CRT_vsprintf at 0x004BC768), so all normal float formatting works.
6. UI Layout Considerations
UIList_AddItem allocates a UIListItem of size 0x444 bytes. The item stores:
- display text pointer (
+0x00) - id code pointer (
+0x04) - measured width/height
- selection state
The list itself is anchored at:
OptionsMenu + 0x44C— item listOptionsMenu + 0x88C— secondary list / iterator backingOptionsMenu + 0x864— current selection index
When you add a new item, UIList_Layout (0x00449D40) and UIList_Render (0x00449C20) will automatically include it. No manual layout math is required.
7. Persisting the New Setting
The registry path is:
HKEY_CURRENT_USER\Software\Raptisoft\Hamsterball
For a new float slider, the easiest compatible approach is to scale it to an integer and store as REG_DWORD:
// Save: value is 0.0..1.0, store as 0..100
DWORD scaled = (DWORD)(value * 100.0f);
RegKey_WriteDWORD(app + 0x54, "FieldOfView", scaled);
// Load: reverse the scale
DWORD raw = RegKey_ReadDWORD(app + 0x54, "FieldOfView", default_value);
float value = raw / 100.0f;
If you want to store a raw float, use Registry_SetValue (REG_BINARY) with 4 bytes, as the game does for the BestTime and Medals blobs. However, DWORD is simpler for human editing in regedit.
8. Left / Right Input Routing (what calls AdjustVolume)
The exact internal vtable dispatch is not fully decompiled here, but the practical path is:
- Player presses Left or Right while a slider item is selected.
UIList_HandleKeyNav/UIList_ActivateCurrentItem(0x00449750) decides whether the current item is a left/right slider.- For slider items, it eventually calls into the menu's override of the input-handler vtable slot, which forwards to:
OptionsMenu_AdjustVolume(this, id_code, delta)at0x00442680.
OptionsMenu_AdjustVolumeupdates the staging value and dirty flag.
For modding, the only function you need to edit is OptionsMenu_AdjustVolume. As long as your new ID code is unique and your item is added with the slider flag set, the existing routing will call your new branch automatically.
9. Build and Test
After editing the source patch or binary:
make clean && make
Or if you are patching the binary directly with Ghidra:
- Apply patches in Ghidra.
- Export the new PE.
- Replace
Hamsterball.exeand test on Wine or Windows. - Open Options menu, move selection to the new slider, press Left/Right, and confirm the decimal text updates.
10. Quick Reference: Useful Global Constants
| Address | Interpretation | Value |
|---|---|---|
_DAT_004CF308 (0x004CF308) |
Slider step as double | 0.1 |
_DAT_004CF310 (0x004CF310) |
Slider maximum as float | 1.0 |
_DAT_004CF368 (0x004CF368) |
Slider minimum as float | 0.0 |
0x004D5EA4 |
String "TQ" |
ID code for texture quality |
0x004D5EA8 |
String "MS" |
ID code for mouse sensitivity |
0x004D5EAC |
String "MV" |
ID code for music volume |
0x004D5EB0 |
String "SV" |
ID code for sound volume |
11. Example: Complete New Slider Patch Summary
This is the high-level change list for adding a FOV decimal slider:
-
OptionsMenu_ctor(0x00442CE0):- Add
UIList_AddItem(this, "FOV:", "FOV", ...); - Add
AthenaString_SprintfToBuffer(buf, "FOV: %.1f", current); UIList_SetTextByName(this, buf, "FOV");
- Add
-
OptionsMenu_AdjustVolume(0x00442680):- Add
__stricmp(id, "FOV")branch with clamp and dirty flag. - Call
AthenaString_SprintfToBuffer+UIList_SetTextByNameto refresh the text.
- Add
-
GraphicsOptionsMenu_Update(0x00441E70):- Add dirty-flag branch that copies
OptionsMenu + 0xE10toApp + 0xYYY.
- Add dirty-flag branch that copies
-
App_SaveAllConfig(0x004284C0):- Add
RegKey_WriteDWORD(..., "FieldOfView", scaled).
- Add
-
Startup read path (near
MouseSensitivityread):- Add
RegKey_ReadDWORD(..., "FieldOfView")and write toApp + 0xYYY.
- Add
That is the full recipe.
🔗 Related Documents
Package Manifest - 01-BOOTS
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/MANIFEST.md)
Package Manifest\n\n- 01-BOOTSTRAP.md\n- 02-GHIDRA-SETUP.md\n- 03-RENAME-RESTORE.md\n- 04-DECOMP-WORKFLOW.md\n- 05-STRUCT-VERIFICATION.md\n- 06-MODDING-PATTERNS.md\n- 07-REIMPL-LESSONS.md\n- 08-TROUBLESHOOTING.md\n- A1-THISCALL.md\n- A2-OBJECT-SPAWNING.md\n- A3-CAMERA-PITFALLS.md\n- INDEX.md\n- README.md\n- scripts/check_server.py\n- scripts/verify_struct_offsets.py\n- templates/DecompileByAddress.java\n- templates/DecompileMultiAddresses.java
🔗 Related Documents
Particle & Trail System
Particle & Trail System
Overview
Hamsterball uses a simple particle system for trail effects behind the ball.
The only particle emitter is Ball_CreateTrailParticles (0x401DD0), which spawns
small ArenaScoreParticle objects in a circular pattern around the ball.
Ball_CreateTrailParticles (0x401DD0)
Creates 9 trail particles arranged in a circle (iterates local_50 from 0 to 0x168
in steps of 0x28, giving 9 particles: 0x168/0x28 = 9).
Algorithm
for each of 9 positions (angle_step = 0x28 in local_50):
1. Get camera matrix from Graphics (via scene->graphics)
cam_right = (fVar3..fVar5) from graphics->0x744+0x5c (3 floats)
cam_up = (fVar6..fVar8) from graphics->0x744+0x68 (3 floats)
2. Calculate angle: sin(Wave_Sin(local_50)), cos(Wave_Cos(local_50))
3. Offset = ball_radius * (sin*cos_right + cos*cos_up)
offset_right = radius * cam_right * sin_angle
offset_up = radius * cam_up * cos_angle
4. Create ArenaScoreParticle particle (0x28 bytes):
ArenaScoreParticle_ctor(ball_scene)
5. Particle position = ball_pos + offset_right - offset_up
position[0] = offset_right.x + ball.pos.x - offset_up.x
position[1] = offset_right.y + ball.pos.y - offset_up.y
position[2] = offset_right.z + ball.pos.z - offset_up.z
6. Particle velocity = offset_right - offset_up (normalized)
velocity = (offset_right - offset_up) * (1.0 / (RNG_Rand(0x14) + 20))
// _DAT_004cf310 = 1.0f division factor
7. Append to scene->0x3b00 (particle list)
App Struct Offsets
| Offset | Type | Description |
|---|---|---|
| ball+0x14 | Scene* | Scene pointer |
| ball+0x164 | Vec3 | Ball position (x,y,z) |
| ball+0x284 | float | Ball radius (27.0f default) |
| scene+0x878 | void* | Graphics subsystem |
| scene+0x3b00 | AthenaList | Particle list head |
| graphics+0x744 | void* | Camera matrix base |
ArenaScoreParticle Particle Struct (0x28 bytes)
Used for both ArenaBoard score popup effect and ball trail particles.
| Offset | Type | Field |
|---|---|---|
| 0x00 | vtable* | Virtual table pointer |
| 0x08 | float | position.x |
| 0x0C | float | position.y |
| 0x10 | float | position.z |
| 0x14 | float | velocity.x |
| 0x18 | float | velocity.y |
| 0x1C | float | velocity.z |
| ... | ... | Additional fields (lifetime, color, etc.) |
FlagWaver System
Water ripples are a mesh deformation system, not a particle system.
Functions
| Address | Name | Description |
|---|---|---|
| 0x46A820 | FlagWaver_dtor | Destructor |
| 0x46A8A0 | FlagWaver_AllocBuffers | Allocate vertex buffers |
| 0x46A930 | FlagWaver_AdvancePhase | Advance ripple phase |
| 0x46A940 | FlagWaver_DeletingDtor | Deleting destructor |
| 0x46A960 | FlagWaver_UpdateVertices | Deform mesh vertices based on ripple |
| 0x46AF30 | FlagWaver_Ctor | Constructor |
| 0x46B070 | FlagWaver_Render | Render ripple effect |
How Water Ripples Work
- FlagWaver_Ctor initializes phase=0, allocates vertex buffer copies
- Each frame: FlagWaver_AdvancePhase increments phase counter
- FlagWaver_UpdateVertices applies concentric sine wave displacement to mesh Y coordinates
- FlagWaver_Render draws the deformed water mesh with alpha blending
- Ball_TestPlaneIntersection checks if ball is touching water surface
🔗 Related Documents
Physics System
Hamsterball Physics System — Reverse Engineering Documentation
Decompiled from Hamsterball.exe (Athena Engine, PE32 i386, image base 0x400000)
Primary function: Ball_Update at 0x405E00 (9,442 bytes, 2,541 instructions)
1. Architecture Overview
The physics system is a single monolithic function (Ball_Update at 0x405E00) that runs every frame for every ball. It handles:
- Timer decay and particle effects
- Collision tree building + spatial queries
- Collision response (3 types: floor/wall/ball-ball)
- Force application with multipliers
- Velocity integration and friction
- Camera following
- Facing angle computation
- Spin physics
- Teleport override
The function is called via vtable[4] (offset +0x10) from Scene_UpdateBallsAndState, wrapped by Ball_UpdateAndAI (0x408390) which adds AI targeting after the physics tick.
2. Ball State Machine
The ball has three operational modes:
| Mode | Flag | Effect |
|---|---|---|
| Normal | ball+0xC4C = 0 |
Full physics, camera follow, boost, friction |
| odd race Shrunk | ball+0xC4C = 1 |
Reduced physics, no camera follow, 0.75× force, no boost |
| Launch | ball+0x2F0 ≥ 100 |
Free trajectory for ~1.67s, no external forces |
Ball_Shrink (0x402200)
Sets the odd race shrunk state:
ball+0xC4C = 1(is_shrunk flag)ball+0x284 = 13.0f(shrink radius — was 26.0f)ball+0x188 = 2.5f(reduce max speed — was 5.0f)- Plays shrink sound from
Scene+0x4D4
Ball_Grow (0x402270)
Restores normal state:
ball+0xC4C = 0(clear is_shrunk)ball+0x284 = 26.0f(restore radius)ball+0x188 = 5.0f(restore max speed)
Ball_ApplyTrajectory (0x403750)
Called when ball hits a boost ramp/launch surface:
- Reads trajectory from
physics_body+0xCA4/CA8/CAC(launch direction) - Normalizes trajectory, scales by
_DAT_004CF3F0 = 0.5 - Damps Y component:
body+0xCA8 *= _DAT_004CF434 = 1.25(adds vertical boost) - Sets
impact_counter = 100atball+0x2F0(prevents force application for ~1.67s) - Sets
ball+0x14D = 1(rotation dirty) - Plays boost sound, creates trail particles
3. Force Application System
Two force application functions, both with the same multiplier chain:
Ball_ApplyForceWithMultipliers (0x402650)
Primary force applier — used for player input and direct forces.
Ball_ApplyForceV2 (0x4016F0)
Secondary force applier — used for collision-derived forces. Adds tube check (complete zeroing).
Guard conditions (all must pass for force to apply):
| Condition | Offset | Check |
|---|---|---|
| Not in tarpit | ball+0x2F9 |
== 0 |
| Force enabled | ball+0x2CC |
== 0 |
| Not frozen | ball+0x808 |
== 0 |
| Counter within limit | ball+0x2F0 |
< 81 |
Force multipliers (applied in order to the magnitude parameter):
| Condition | Offset | Multiplier | Value | Notes |
|---|---|---|---|---|
| Recent impact | ball+0x2F0 |
×_DAT_004CF380 |
×0.25 | First few frames after hit |
| In tube | ball+0x324 |
×_DAT_004CF378 |
×0.0 | Complete freeze in tubes (V2 only) |
| On ice | ball+0xC5C |
×_DAT_004CF374 |
×0.2 | Nearly zero on ice; also sets angular velocity ×6.0 |
| Dizzy/falling | ball+0xC4C |
×_DAT_004CF36C |
×0.75 | 25% reduction when falling |
Velocity accumulation:
ball+0x170 += dir_x × magnitude
ball+0x174 += dir_y × magnitude
ball+0x178 += dir_z × magnitude
Facing direction (only when direction is non-zero):
ball+0x748 = 0(XZ flat):angle = atan2(dir_x, dir_z)ball+0x748 = 1(tilted):angle = atan2(-dir_y, dir_z)ball+0x748 = 2(XY vertical):angle = atan2(dir_x, dir_y)- Store at
ball+0x198, setball+0x19C = 1
4. Collision System
Collision Types
| Type | Meaning | Response |
|---|---|---|
| 1 | Ball-ball | Push apart, score award, clack sound |
| 2 | Wall | Reflect velocity, friction, spin update |
| 5 | Floor | Camera follow, boost counter, slope gravity |
Type 1: Ball-Ball Collision (at 0x406BD3)
When two balls collide:
- If
ball+0x2EC(boost counter) > 1 and not on_ramp:Ball_ApplyTrajectory(launch) - Compute push direction between ball centers
- Apply push via
vtable[6](SetPosition) on both balls Sound_Play3Dat collision point- Knockoff scoring: If one ball is heavier (
ball+0x284), awardDifficulty_GetTimeModifier(app, 500.0)points to the heavier ball's player - Display "+%d" popup via
AthenaString_Formatat address0x4CF500 - Set string timer to 200 frames (
ball+0x0C = 0xC8)
Type 2: Wall Collision (at 0x406B80)
When ball hits a wall surface:
- Read collision normal from result struct (
piVar16[8..10]) - Reflect velocity across normal:
v_reflected = v - 2(v·n)n - If on ice (
ball+0x324 == 0): compute surface friction using the collision node's direction vector - The direction vector from the collision node is used to:
- Determine if the ball is moving "with" or "against" the surface
- Apply friction proportional to the surface's physical properties
- Update the ball's up-direction (
ball+0x6A..0x6C) for ramp/slope handling
Key insight: The collision node's direction vector (CollisionNode+0x20..0x28 in the spatial tree) stores the surface's tangent/normal information. This is what makes the ball slide along walls instead of stopping dead.
Type 5: Floor Collision (at 0x407319)
When ball touches floor geometry:
- Check floor depth (
[edi+0x54]) against threshold (_DAT_004CF420 ≈ 0.0) - If NOT shrunk (
ball+0xC4C == 0):- Set
ball+0x2E9 = 1(on_ramp flag) Scene_SetCamera(ball, 1)— camera follows ballGraphics_SetViewport— update camera viewport
- Set
- If player (
ball+0x18 != -1):- Boost counter logic: increment
ball+0x2ECwhen touching floor - OOB detection: compare ball position against viewport bounds
- If out of bounds: set
ball+0x2E8 = 1(fell off flag)
- Boost counter logic: increment
When shrunk (ball+0xC4C = 1):
- SKIPS
Scene_SetCameracall — camera doesn't follow falling ball - SKIPS
on_rampflag set — no ramp detection during fall - SKIPS boost counter increment — can't charge boost while falling
- Jump target:
0x40743Dwhich goes to player_index check
How is_shrunk Affects Collision Direction
The collision normal is NOT modified when shrunk. The collision tree is built and queried identically regardless of the is_shrunk flag. The direction vector flows through the same reflection/friction code. The is_shrunk flag only affects:
- Camera: No
Scene_SetCameracall when shrunk (camera stays at last position) - Boost: No boost counter accumulation when shrunk
- Friction: Different spin friction calculation (0.25× multiplier at 0x4CF380 when shrunk vs normal friction)
- Force: 0.75× multiplier on all applied forces when shrunk
5. Gravity and Velocity Integration
Original Game Flow (Ball_Update at 0x405E00)
The original does NOT use simple Euler integration. The flow is:
- Save previous position:
prev_pos = pos(ball+0x158..0x160 = ball+0x164..0x16C) - Clear external velocity:
ball+0x170..0x178 = 0 - Build collision tree:
SpatialTree_ctorwith gravity scale fromball+0x278 - Query collision results:
Scene vtable[1]with AABB frompos ± radius - Process collision results (types 1, 2, 5)
- Compute velocity from position delta:
velocity = pos - prev_pos - Apply external forces:
pos += external_velocity - Clear external velocity:
ball+0x170..0x178 = 0again - Facing angle:
atan2from velocity components - Spin physics: 3 iterations of friction/accumulation
- Display position lerp:
display_pos += (pos - display_pos) × lerp_factor - Teleport override: If
ball+0xC3C, override pos fromball+0xC40..0xC48
Critical difference from simple Euler: The original computes velocity AFTER collision resolution, not before. This means the velocity stored at ball+0x170..0x178 is actually the RESULT of collision processing, not an input to it. The collision tree determines where the ball ends up, and velocity is derived from the position change.
Reimpl Physics Flow (UpdatePhysics in win32_main.c)
The reimpl uses standard semi-implicit Euler:
- Apply gravity:
vy -= gravity * 500 * dt - Apply input force:
vx/vz += input * force_scale * dt - Apply damping:
vx/vz *= friction - Integrate position:
pos += vel * dt - Resolve collisions: push out + reflect velocity
- Slope gravity:
vx/vz += gravity * normal × dt
Key discrepancy: The reimpl applies forces BEFORE collision, the original applies them AFTER. This is why the reimpl can "kill momentum" during jumps — the collision response removes the normal component of velocity that was added by input forces, while the original never adds input forces to a velocity that would be immediately removed by collision.
6. Airborne Physics
How the Ball Goes Airborne
In the original game, there is NO explicit "jump" mechanic. The ball goes airborne through:
- Rolling off an edge: Ball passes beyond floor geometry → no type-5 results → gravity accumulates
- Launch ramp:
Ball_ApplyTrajectorysetsimpact_counter = 100and applies trajectory vector - Ball-ball collision: Knockoff sends ball upward with reflected velocity
- Slope/ramp transition: Ball rolls up a ramp, normal changes, velocity points up
What Happens While Airborne
When the ball is NOT touching any floor surface:
- No type-5 collision results → floor processing doesn't fire
- Gravity accumulates through the collision tree's gravity_scale parameter
- Camera stops following (Scene_SetCamera not called without type-5)
- Input forces still apply at 0.75× if dizzy, full if not
- Friction changes: No ground friction, only air friction (0.85× per frame =
_DAT_004CF4C0)
The "Momentum Kill" Issue
The problem in the reimpl: when the ball is airborne and moving horizontally, the collision response code treats any contact with geometry as a "wall hit" and reflects the velocity. This can kill horizontal momentum if the ball clips a ledge or edge while airborne.
In the original, this doesn't happen because:
- Forces are applied AFTER collision resolution
- The collision tree computes the final position directly
- The ball's velocity is DERIVED from the position change, not used as an input
- The collision node's direction vector guides the ball along surfaces, not just reflecting
7. Key Physics Constants (Verified from .rdata)
| Address | Value | Name | Usage |
|---|---|---|---|
0x4CF310 |
1.0 | UNIT_VALUE |
General purpose 1.0 constant |
0x4CF368 |
0.0 | GROUND_THRESHOLD |
Floor detection threshold |
0x4CF36C |
0.75 | DIZZY_MULT |
Force multiplier when is_shrunk (odd race) |
0x4CF374 |
0.2 | ON_ICE_MULT |
Force multiplier on ice surfaces |
0x4CF378 |
0.0 | IN_TUBE_MULT |
Force multiplier in tube sections |
0x4CF380 |
0.25 | FIRST_FRAME_MULT |
Force multiplier on first frame / after impact |
0x4CF3E8 |
6.0 | ICE_ANGULAR_SCALE |
Angular velocity scale on ice |
0x4CF3F0 |
0.5 | LAUNCH_TRAJECTORY_SCALE |
Launch direction normalization scale |
0x4CF418 |
3.0 | SPEED_ACCUM_WRAP |
Speed gauge wrap value |
0x4CF434 |
1.25 | Y_DAMP |
Vertical velocity damping on launch |
0x4CF4C0 |
0.85 | SPEED_FRICTION |
Per-frame velocity friction |
0x4CF520 |
0.025 | LAUNCH_DIR_SCALE |
Launch direction scaling |
0x4CF540 |
0.98 | SPIN_DECAY_MULT |
Per-frame spin timer decay |
8. Ball Struct Layout (Key Physics Fields)
| Byte Offset | Decomp Index | Type | Name | Description |
|---|---|---|---|---|
| +0x0C | param_1[3] | int | string_timer |
Countdown (200=show). Frees display string at 0 |
| +0x10 | param_1[4] | ptr | app |
App pointer |
| +0x14 | param_1[5] | ptr | scene |
Scene pointer |
| +0x18 | param_1[6] | int | player_index |
-1 = AI, 0+ = human |
| +0x158 | param_1[0x56] | Vec3 | prev_pos |
Previous frame position |
| +0x164 | param_1[0x59] | Vec3 | pos |
Current position |
| +0x170 | param_1[0x5C] | Vec3 | vel |
Velocity (cleared each frame, recomputed) |
| +0x188 | param_1[0x62] | float | max_speed |
5000.0 (normal) / 2.5 (shrunk, odd race) |
| +0x198 | param_1[0x66] | float | facing_angle |
Target rotation angle |
| +0x19C | param_1[0x67] | byte | facing_dirty |
1 = rotation needs update |
| +0x1A4 | param_1[0x69] | ptr | physics_body |
Scene physics body (trajectory, friction) |
| +0x284 | param_1[0xA1] | float | radius |
26.0 (normal) / 13.0 (shrunk, odd race) |
| +0x2E8 | byte | fell_off |
Ball fell off level edge | |
| +0x2E9 | byte | on_ramp |
Ball is on a ramp/slope surface | |
| +0x2F0 | param_1[0xBC] | int | impact_counter |
≥100 = in launch, ≥81 = no force |
| +0x2F9 | byte | in_tarpit |
Tarpit state (blocks force) | |
| +0x313 | byte | is_8ball |
1 = AI 8-ball (dispatches differently) | |
| +0x324 | param_1[0xC9] | byte | in_tube |
In tube section (zeroes force) |
| +0x748 | param_1[0x1D2] | int | gravity_plane |
0=XZ flat, 1=tilted, 2=XY vertical |
| +0x808 | int | freeze_counter |
>0 = frozen, no force | |
| +0xC4C | byte | is_shrunk |
1 = odd race shrunk state (E:SHRINK) | |
| +0xC50 | param_1[0x314] | float | spin_timer |
Decayed by 0.98× per frame |
| +0xC5C | param_1[0x317] | int | momentum_transfer |
Flag for ice momentum |
| +0xC3C | param_1[0x30F] | byte | teleport_active |
1 = teleport pending |
| +0xC40 | param_1[0x310] | Vec3 | teleport_dest |
Teleport destination |
9. Function Call Graph (Ball_Update)
Ball_Update (0x405E00)
├── Sound_Play3DAtPosition (0x458EE0) ×2
├── operator_new (0x4BA57B) ×4
│ ├── ArenaScoreParticle_ctor (0x44AD50)
│ ├── SpatialTree_ctor (0x463330)
│ └── CollisionNode_ctor (0x466CF0)
├── RNG_Rand (0x45DD60) ×4
├── AthenaList_Append (0x453810/0x453780) ×4
├── Difficulty_GetTimeModifier (0x428ED0) ×5
├── AthenaString_Format (0x466C70) ×2
├── __ftol2 (0x4BA754) ×5
├── Mesh_FindClosestCollision (0x465D90) ×2
├── Scene_SetCamera (0x419FA0) ×1
├── Graphics_SetViewport (0x454B50) ×1
├── Math_Atan2Angle (0x457DE0) ×4
├── Scene_CheckPath (0x457EC0) ×1
├── Wave_Sin (0x457DA0) ×1
├── Wave_Cos (0x457DC0) ×1
├── Collision_GradientEval_Stub (0x458190) ×1
├── Sound_CalculateDistanceAttenuation (0x466750) ×1
├── Vec3List_Free (0x453250) ×2
├── _free (0x4BA576) ×2
├── Gfx_RotateY (0x457BB0) ×1
├── Timer_Init (0x457A40) ×1
├── AthenaList_Init/Clear ×6
└── [vtable indirect calls] ×8
├── vtable[1] (QueryCollision)
├── vtable[5] (BuildSpatialTree)
├── vtable[6] (SetPosition)
├── vtable[8] (SpecialAction/Teleport)
├── vtable[0x34] (RenderCallback)
└── vtable[0x74] (SceneCallback)
10. Known Issues in Reimpl Physics
-
Momentum kill on airborne collision: Reimpl applies forces before collision, original applies after. This causes horizontal momentum to be lost when the ball clips geometry while airborne.
-
No collision tree: Reimpl uses sphere-vs-triangle direct tests. Original uses SpatialTree + CollisionNode hierarchy with direction vectors that guide the ball along surfaces.
-
Simple velocity reflection: Reimpl reflects velocity with fixed bounce=0.3. Original uses the collision node's direction vector for smooth sliding along walls.
-
No is_shrunk state: Reimpl has no
ball+0xC4Cequivalent. The odd race shrink mechanic (Ball_Shrink/Ball_Grow) is not implemented. -
No launch trajectory system: Reimpl has no
Ball_ApplyTrajectoryequivalent. Boost ramps and launch surfaces are not implemented. -
Gravity before collision: Reimpl applies gravity at the start of the frame, original integrates it through the collision tree's gravity_scale parameter.
🔗 Related Documents
Player Clones Mod (v13)
Player Clones Mod (v13)
Overview
Spawns AI-controlled clone balls for any player slot (1-4) in both races and arenas.
Clones chase and attack all entities everywhere on the track, regardless of Y level or position.
Hotkeys
- 1 — Spawn Player 1 clone
- 2 — Spawn Player 2 clone
- 3 — Spawn Player 3 clone
- 4 — Spawn Player 4 clone
Features
- Works in both races and arenas
- Clones chase all entities everywhere (no "going home" behavior)
- Can spawn P2-P4 clones even when original players don't exist (uses P1 as appearance source)
- Per-player color via copied appearance from source ball
- Up to 8 simultaneous clones
- Automatic cleanup of dead/stale clones
What v13 Fixes (vs V8/V11)
- "Going home" bug — Clear
0x2E8(respawn needed flag) every frame. This preventsBall_FindClosestRespawnPointfrom teleporting clones back to spawn. - "Not chasing" bug — Clear ALL force guards every frame:
0x2F9(falling),0x2CC(block),0x808(state),0x2F0(impact count). V8 only cleared0x2F8and0x808. - "All black" in arenas — Copy render context index (
0x154), render mode flags (0x748,0x182), and render state flags (0x6FC,0x700,0x708,0x70D,0x734) from source ball. Without these, the clone uses an uninitialized render context. - P2-4 spawning — P1 fallback already worked in V8. Added
0x310=1and0x29C=1.0at spawn to prevent respawn behavior. - "Nothing spawned" in races — Clear all guard flags at spawn time, not just in the AI loop.
How It Works
Hook Point
0x0041B540 — Scene_UpdateBallsAndState. Called every frame for both races and arenas.
AI Function
Calls 0x004222D0 (Computer AI) with ball swap trick:
- Temporarily replaces player table entry with clone pointer
- Sets control mode to
0x63(AI) - Calls AI function which finds nearest target in
scene+0x29D4list - Computes direction force toward target
- Restores original player table entry
- Applies force via
vtable[5](Ball_ApplyForceWithMultipliers)
Force Guard Clearing
Every frame, the AI loop clears these flags on each clone:
| Offset | Name | Why Clear |
|---|---|---|
0x2E8 |
respawn_needed | Prevents teleport to respawn point ("going home") |
0x2E9 |
sticky_limit | Prevents stuck-on-limit behavior |
0x2F8 |
respawn_in_progress | Prevents respawn sequence |
0x2F9 |
falling | Allows force application while "falling" |
0x2CC |
block | Allows force application while "blocked" |
0x808 |
state | Must be 0 for AI to process ball |
0x2F0 |
impact_count | Must be < 0x51 (81) for force to apply |
CHASE/HOME Values
Set to 99999999.0 (0x4CBEBC20) at spawn and every frame. These are used by the 8ball AI path if activated.
File
- Script:
player_clones_CE_script.CEA
🔗 Related Documents
Player Collision Detection
Player Collision Detection — Detecting When a Player Gets Bumped
Overview
This document covers every viable approach for detecting when the player
ball collides with a badball (NPC 8-ball) or another player ball in Hamsterball.
Each approach is traced from Ghidra decompilation of the original binary,
with exact addresses, struct offsets, trade-offs, and working code patterns.
Table of Contents
- How the Original Game Detects Ball-Ball Collisions
- Ball Struct Reference
- Collision Entry Struct Layout
- Vtable Layout (Player Ball vs 8-Ball)
- Approach 1: Code Cave Hook (Proven — Existing Mod)
- Approach 2: Detour on Ball_Update Entry
- Approach 3: Vtable Patching (Swap vtable[4])
- Approach 4: Background Polling Thread
- Approach 5: Hook the Impulse Function (vtable[6])
- Approach 6: Hook Collision Event Dispatchers
- Approach 7: Custom Distance Check (No Hooks)
- Approach 8: Reimplementation
- Decision Matrix: Which Approach to Use
- Pitfalls and Lessons Learned
1. How the Original Game Detects Ball-Ball Collisions
Call Chain
Scene_UpdateBallsAndState (0x41B540)
│ Iterates Scene+0x29D4 (Ball AthenaList)
│ For each ball: calls vtable[4] (Ball_Update for player, AI tick for 8-ball)
│
└─ Ball_Update (0x405E00) — player ball per-frame physics tick
│ 23-phase physics pipeline
│
├─ Phase: Build collision tree
│ CollisionMesh vtable[6] (0x456140) → builds collision result list
│ at PhysicsBody+0x18 (AthenaList of collision entries)
│
├─ Phase: Iterate collision results (0x406B77–0x407178)
│ Loop: walk collision entry array at PhysicsBody+0x424
│ Each entry (EBP/piVar16):
│ [EBP+0x00] == 2 → wall collision (type 2)
│ [EBP+0x00] == 1 → ball-ball collision (type 1) ← TARGET
│
└─ For type==1 (ball-ball):
0x406C90: MOV EDI, [EBP+0x0C] ; EDI = OTHER BALL pointer
0x406C93: FLD [ESI+0x16C] ; this ball Z
0x406C99: FLD [ESI+0x168] ; this ball Y
0x406CA5: FLD [ESI+0x164] ; this ball X
0x406CB1: FLD [EDI+0x164] ; other ball X
... direction = (this - other) × 0.025, normalize ...
0x406DA6: CALL [EDX+0x18] ; ApplyForce to OTHER ball (push away)
0x406DBD: CALL [EDX+0x18] ; ApplyForce to THIS ball (push away)
0x406FD1: FLD [EDI+0x284] ; speed comparison begins
0x406FF7: CMP [ESI+0x18], -1 ; is THIS ball a player?
0x407009: CALL Difficulty_GetTimeModifier(scene, 500.0)
... add 500 to player score ...
0x407094: FLD [EDI+0x284] ; reverse speed comparison
0x4070BA: CMP [EDI+0x18], -1 ; is OTHER ball a player?
... mirror scoring for reverse case ...
Key Insight: Symmetric Double-Fire
Ball_Update runs for both balls in a collision pair. When Player 1 bumps
an 8-ball, the collision is detected from both perspectives:
- During Player 1's
Ball_Update: ESI=Player1, EDI=8ball - During 8-ball's
Ball_Update: ESI=8ball, EDI=Player1
This means any hook at the collision point fires twice per collision event.
You must deduplicate — either by pointer comparison (ESI < EDI to count once)
or by tracking collision IDs ([EBP+0x64]).
2. Ball Struct Reference
Key Offsets for Collision Detection
| Offset | Type | Field | Description |
|---|---|---|---|
+0x10 |
void* |
scene_ptr (sound) | Used for Sound_Play3D (Scene+0x43C) |
+0x14 |
void* |
scene_ptr (collision) | Used for Mesh_FindClosestCollision (Scene+0x8B0) |
+0x18 |
int32 |
player_index | -1 = NPC/badball, 0-3 = Player 1-4 |
+0x164 |
float |
pos_x | Current world position X |
+0x168 |
float |
pos_y | Current world position Y |
+0x16C |
float |
pos_z | Current world position Z |
+0x170 |
float |
vel_x | Velocity accumulator X (force) |
+0x174 |
float |
vel_y | Velocity accumulator Y (force) |
+0x178 |
float |
vel_z | Velocity accumulator Z (force) |
+0x1A4 |
void* |
physics_body | CollisionMesh/PhysicsBody pointer |
+0x284 |
float |
radius | Collision radius (default ~27.0) |
+0x2E8 |
byte |
respawn_flag | Set during respawn handling |
+0x2E9 |
byte |
impact_shatter | Sticky flag (E:LIMIT collision) — NEVER use for ground check |
+0x2EC |
int32 |
collision_state | Counter (0 = none, 1 = active, 2+ = depth) |
+0x2F0 |
int32 |
force_count | Number of forces applied this frame |
+0x2F4 |
int32 |
dizzy_immunity_timer | Dizzy immunity timer (int32, frames remaining) |
+0x2F9 |
byte |
frozen | Ball is frozen/stuck |
+0x2FC |
int32 |
fall_timer | Countdown when falling |
+0x324 |
byte |
in_tube | Skip collision processing when set |
+0x748 |
int32 |
gravity_axis | 0=Y, 1=X, 2=Z (gravity plane) |
+0x808 |
int32 |
teleport_active | Non-zero = teleport in progress |
+0x810 |
AthenaList |
path_list | Ball path tracking |
+0xC28 |
char* |
popup_text | Score popup string (freed each frame) |
+0xC2C |
char[] |
section_filter | Current collision section name |
Scene Offsets for Ball Access
| Offset | Type | Field | Description |
|---|---|---|---|
Scene+0x178 |
void* |
— | (App→Scene pointer is at App+0x178) |
Scene+0x29D4 |
AthenaList |
balls | Player balls list |
Scene+0x29D8 |
int32 |
ball_count | Number of player balls |
Scene+0x2DE0 |
void** |
ball_array | Array of ball pointers |
Scene+0x3204 |
AthenaList |
eight_balls | NPC 8-ball list |
Scene+0x3208 |
int32 |
eight_ball_count | Number of 8-balls |
Scene+0x3610 |
void** |
eight_ball_array | Array of 8-ball pointers |
Scene+0x8B0 |
void* |
collision_level | CollisionLevel for raycasts |
Scene+0x43C |
void* |
sound_manager | Sound system |
Global Access
void* app = *(void**)0x005341E0; // App global
void* scene = *(void**)((char*)app + 0x178); // App+0x178 = Scene
3. Collision Entry Struct Layout
Each collision entry is accessed via EBP (or piVar16 in decompiled C).
The struct is allocated as 32 bytes (8 DWORDs) via operator_new(0x20).
CollisionEntry {
+0x00 (int32) type // 1 = ball-ball, 2 = wall, 5 = floor
+0x04 (???) padding
+0x08 (???) padding
+0x0C (void*) other_ball // When type==1: pointer to the other ball
+0x10–0x1C ??? // Unknown fields
+0x20 (float) normal_x // Surface normal X (collision response)
+0x24 (float) normal_y // Surface normal Y
+0x28 (float) normal_z // Surface normal Z
+0x2C (float) collision_pt // Collision point or distance
+0x30 (float) normal2_x // Secondary normal components
+0x34 (float) normal2_y
+0x38 (float) normal2_z
+0x3C–0x60 ??? // Additional collision data
+0x64 (int32) collision_id // Dedup token (compared against EBP register)
}
Decoded from Decompiled Code
// From Ball_Update decompilation (lines 486–624):
while (piVar16 != NULL) {
if (*piVar16 == 2) { // type == 2: wall collision
if (piVar16[0x19] == unaff_EBP) { // collision_id matches
// Store ball position as "last wall contact" (ball+0x2DC)
}
}
if (*piVar16 == 1) { // type == 1: ball-ball collision
if (piVar16[0x19] == unaff_EBP) { // collision_id matches
// Apply trajectory boost (catapult-like effect)
// Check gravity plane and call vtable[8] (bounce)
}
if (*piVar16 == 1) { // Still type 1 (redundant check)
piVar2 = (int*)piVar16[3]; // other_ball = entry[3] = +0x0C
// Calculate direction between balls
dir_y = (ball[0x5A] - other[0x5A]) * 0.025; // × position_scale
dir_x = (ball[0x59] - other[0x59]) * 0.025;
dir_z = (ball[0x5B] - other[0x5B]) * 0.025;
// Normalize direction, clamp to minimum 3.0
magnitude = sqrt(dir_x² + dir_y² + dir_z²);
if (magnitude > 0 && magnitude < 3.0)
scale = 3.0 / magnitude;
else
scale = 1.0;
// Apply impulse to OTHER ball (push away)
(*other->vtable[6])(other, -dir_x*scale, -dir_z*scale, -dir_y*scale, 1.0);
// Apply impulse to THIS ball (push away)
(*this->vtable[6])(this, dir_x*scale, dir_z*scale, dir_y*scale, dir_x*scale);
// Sound effect (3D positioned)
Sound_Play3D(scene->sound_mgr, ball_x, ball_y, ball_z);
// Scoring: if THIS ball is faster, OTHER ball gets bumped
if (other->radius < this->radius * 0.7) {
(*other->vtable[8])(other); // bounce callback
if (this->player_index != -1) {
// Add 500 score × difficulty modifier
score = Difficulty_GetTimeModifier(scene, 500.0);
app_score[player_index] += score;
this->popup_text = format("+%d", score);
this->freeze_counter = 200;
}
}
// Scoring: reverse — if OTHER ball is faster, THIS ball gets bumped
if (this->radius < other->radius * 0.7) {
(*this->vtable[8])(this); // bounce callback
if (other->player_index != -1) {
score = Difficulty_GetTimeModifier(scene, 500.0);
app_score[other->player_index] += score;
other->popup_text = format("+%d", score);
other->freeze_counter = 200;
}
}
}
}
// Advance to next collision entry
...
}
Collision Constants
| Address | Value | Description |
|---|---|---|
0x4CF520 |
0.025 |
Position scale factor for direction calculation |
0x4CF508 |
0.7 (double) |
Speed comparison multiplier (radius ratio threshold) |
0x4CF418 |
3.0 |
Minimum collision direction magnitude |
0x4CF518 |
-0.5 (double) |
Direction dot-product threshold for sound vs silent hit |
0x4CF510 |
0.04 |
Surface angle threshold |
0x4CF48C |
2.0 |
Surface speed threshold (for sound playback) |
0x4CF3E0 |
0.5 (double) |
Speed factor |
0x4CF3C8 |
1.0 (double) |
Comparison threshold |
0x4CF308 |
0.1 (double) |
Impact multiplier |
0x4CF380 |
0.25 |
First-frame force damping |
0x4CF378 |
0.0 |
In-tube force damping (zeroes all force) |
0x4CF374 |
0.2 |
On-ice force damping |
0x4CF36C |
0.75 |
Dizzy force damping |
4. Vtable Layout (Player Ball vs 8-Ball)
Player Ball Vtable at 0x4CF314
| Index | Offset | Address | Function |
|---|---|---|---|
| 0 | +0x00 | 0x402A50 |
Destructor |
| 1 | +0x04 | 0x4015B0 |
One-time init (SetupCollisionRender) |
| 2 | +0x08 | 0x403DC0 |
Unknown |
| 3 | +0x0C | 0x402A70 |
Unknown |
| 4 | +0x10 | 0x405E00 |
Ball_Update (per-frame physics tick) |
| 5 | +0x14 | 0x401590 |
Unknown |
| 6 | +0x18 | 0x4016F0 |
Ball_ApplyForceV2 (gravity-plane-aware force) |
| 7 | +0x1C | 0x402C10 |
Unknown |
| 8 | +0x20 | 0x409050 |
Bounce callback (called on significant hit) |
8-Ball Vtable at 0x4CF3A0
| Index | Offset | Address | Function |
|---|---|---|---|
| 0 | +0x00 | 0x4027F0 |
Destructor |
| 1 | +0x04 | 0x405100 |
One-time init (InitPhysicsDefaults) |
| 2 | +0x08 | 0x402DE0 |
Unknown |
| 3 | +0x0C | 0x402A70 |
Unknown (shared with player) |
| 4 | +0x10 | 0x408390 |
AI tick (per-frame, calls Ball_Update internally) |
| 5 | +0x14 | 0x401590 |
Unknown (shared with player) |
| 6 | +0x18 | 0x402650 |
Ball_ApplyForceWithMultipliers (simpler force) |
| 7 | +0x1C | 0x402C10 |
Unknown (shared with player) |
| 8 | +0x20 | 0x409480 |
Bounce callback (8-ball version) |
Important Notes
-
vtable[4] is the per-frame update. For player balls it's
Ball_Update
(0x405E00). For 8-balls it's the AI tick (0x408390), which internally calls
Ball_Update(at 0x4083BD:CALL 0x405E00). Both run the collision loop. -
vtable[6] is the impulse/force function. Both player and 8-ball versions
accumulate velocity at+0x170/+0x174/+0x178and apply multipliers based on
ball state (frozen, dizzy, in-tube, on-ice). The player version
(Ball_ApplyForceV2, 0x4016F0) has gravity-plane-awareness for facing angle
computation. The 8-ball version (Ball_ApplyForceWithMultipliers, 0x402650)
uses different scale constants but the same core logic. -
vtable[8] is the bounce callback, called when a collision is "significant"
(the other ball's radius × 0.7 is smaller than this ball's radius). This is
the "you got bumped hard" indicator.
Approach 1: Code Cave Hook
Status: PROVEN — Working mod exists (mods/8ball_hit_detect/)
Concept
Patch a JMP instruction at a strategic point inside Ball_Update's collision
loop to redirect execution to a hand-assembled code cave. The cave checks ball
types, records the hit, then executes the original instruction and jumps back.
Hook Point: 0x406FD1
Original instruction: FLD DWORD [EDI+0x284] (6 bytes: D9 87 84 02 00 00)
At this point in the code:
ESI= this ball (the one running Ball_Update)EDI= other ball (collision partner, loaded at 0x406C90)- Both balls are confirmed colliding (type==1 check passed)
- Impulse forces have already been applied (0x406DA6, 0x406DBD)
- Speed comparison is about to begin
This is the scoring section — the game is about to check which ball was
moving faster and award score. Perfect for detection.
Code Cave Logic
; Entry: ESI = this ball, EDI = other ball
PUSHAD
MOV EAX, [ESI+0x18] ; this ball's player_index
MOV EBX, [EDI+0x18] ; other ball's player_index
; Check: is this a player→8-ball collision?
CMP EAX, 0xFFFFFFFF ; is this ball an 8-ball?
JE .check_case2 ; yes → check if other is player
CMP EBX, 0xFFFFFFFF ; is other ball an 8-ball?
JNE .done ; both are players → skip (use different handler)
JMP .hit_detected ; this=player, other=8-ball → HIT
.check_case2:
CMP EBX, 0xFFFFFFFF ; is other ball also 8-ball?
JE .done ; both 8-balls → skip
; this=8-ball, other=player → HIT (player got bumped)
.hit_detected:
INC DWORD [g_hit_count] ; increment counter
; Store player_index for polling thread
CMP EAX, 0xFFFFFFFF
JNE .esi_is_player
MOV ECX, EBX ; EDI is the player
JMP .set_flag
.esi_is_player:
MOV ECX, EAX ; ESI is the player
.set_flag:
INC ECX ; convert to 1-based (0 = no pending)
MOV DWORD [g_hit_pending], ECX
.done:
POPAD
; Execute original instruction
FLD DWORD [EDI+0x284]
; Jump back to 0x406FD1 + 6
JMP 0x00406FD7
Implementation Pattern
// Code cave is assembled at runtime into VirtualAlloc'd memory.
// The hook site is patched with: JMP <cave> + NOP
// A background thread polls g_hit_pending and writes to hitlog.txt.
//
// CRITICAL: Never call C functions from the code cave itself.
// FPU/stack corruption crashes the game. Use the volatile flag + polling
// thread pattern (see Pitfalls section).
static volatile DWORD g_hit_count = 0;
static volatile DWORD g_hit_pending = 0; // 0 = none, 1-4 = player index + 1
Alternative Hook Points
| Address | Instruction | Context | Best For |
|---|---|---|---|
0x406FD1 |
FLD [EDI+0x284] |
After impulse, before scoring | Hit detection + scoring |
0x406C90 |
MOV EDI, [EBP+0x0C] |
Other ball just identified | Modify impulse before it applies |
0x406BD3 |
CMP [EBP], 0x1 |
Type check (earliest) | Filter collision pairs before physics |
0x406DA6 |
CALL [EDX+0x18] |
First impulse call (to other ball) | Intercept/modify force direction |
0x406DBD |
CALL [EDX+0x18] |
Second impulse call (to this ball) | Intercept/modify force direction |
Pros
- Direct access to both ball pointers at collision time
- Minimal overhead (a few comparisons + one memory write)
- Can read/modify any ball field (position, velocity, radius, player_index)
- Can modify the collision response (knockback, score, sound)
- Proven working in production mod
Cons
- Requires hand-assembled x86 machine code (error-prone)
- Must not call C functions from the cave (FPU/stack corruption)
- Symmetric double-fire: fires for both balls in a pair (need dedup)
- Hook site is hardcoded to specific game version (byte signature check required)
- Cannot easily add complex logic (use volatile flag + polling thread instead)
Working Reference
- Source:
mods/8ball_hit_detect/8ball_hit_detect.c - Build:
i686-w64-mingw32-gcc -shared -o bass.dll 8ball_hit_detect.c -lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc -Wl,--add-stdcall-alias - Install: Rename original
bass.dll→bass_real.dll, copy proxybass.dll
Approach 2: Detour on Ball_Update Entry
Status: Theoretical (pattern proven in tools/<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>/)
Concept
Hook the entry point of Ball_Update (0x405E00) with a standard 5-byte JMP
detour. Your C function runs before the physics tick, giving you a chance to
inspect ball state. After your function returns, the original Ball_Update
runs normally via a trampoline.
Implementation
typedef void (__fastcall *BallUpdate_t)(void *ball);
static BallUpdate_t g_orig_Ball_Update = NULL;
static unsigned char g_trampoline[16];
// Previous frame positions for delta detection
static float g_prev_x[4] = {0}; // per player
static float g_prev_y[4] = {0};
static float g_prev_z[4] = {0};
void __fastcall hook_Ball_Update(void *ball, void *edx_dummy) {
// Read ball state BEFORE update
int player_idx = *(int*)((char*)ball + 0x18);
float x = *(float*)((char*)ball + 0x164);
float y = *(float*)((char*)ball + 0x168);
float z = *(float*)((char*)ball + 0x16C);
if (player_idx >= 0 && player_idx < 4) {
// After update, the collision list at ball+0x1A4 will have results
// We can check them in a POST-update hook
}
// Call original
g_orig_Ball_Update(ball, NULL);
// POST-update: check collision results
if (player_idx >= 0) {
void *physics = *(void**)((char*)ball + 0x1A4);
if (physics) {
// Read collision list at physics+0x18
int count = *(int*)((char*)physics + 0x1C);
void **entries = *(void**)((char*)physics + 0x424);
for (int i = 0; i < count && entries; i++) {
int *entry = (int*)entries[i];
if (entry && entry[0] == 1) { // type == 1 (ball-ball)
void *other = (void*)entry[3]; // other ball
if (other) {
int other_idx = *(int*)((char*)other + 0x18);
printf("Player %d hit by ball %d (idx=%d)\n",
player_idx, other_idx != -1 ? other_idx+1 : -1);
}
}
}
}
}
}
Hook Installation (5-byte JMP detour)
static int install_detour(void *target, void *hook, unsigned char *trampoline) {
DWORD oldProtect;
unsigned char *t = (unsigned char *)target;
VirtualProtect(t, 16, PAGE_EXECUTE_READWRITE, &oldProtect);
// Copy original bytes to trampoline
memcpy(trampoline, t, 5); // copy first 5 bytes
// Append JMP back to target+5
trampoline[5] = 0xE9;
*(unsigned long*)(trampoline + 6) =
(unsigned long)((char*)target + 5 - (char*)(trampoline + 5) - 5);
// Make trampoline executable
DWORD tp;
VirtualProtect(trampoline, 16, PAGE_EXECUTE_READWRITE, &tp);
// Overwrite target: JMP rel32
unsigned long rel = (unsigned long)((char*)hook - (char*)target - 5);
t[0] = 0xE9;
*(unsigned long*)(t + 1) = rel;
FlushInstructionCache(GetCurrentProcess(), target, 5);
return 1;
}
Pros
- Can inspect state both BEFORE and AFTER the physics tick
- Written in C (no hand-assembled machine code)
- Can call C library functions (printf, file I/O) safely
- Can access the full collision result list after Ball_Update processes it
- Clean uninstall (restore original bytes)
Cons
- The collision list at
physics+0x18may be in an inconsistent state
post-update (some entries may have been processed and cleared) - The AthenaList iteration uses an internal index at
physics+0x408that
wraps at 256 — reading it externally may miss entries or get stale data - Trampoline requires at least 5 bytes of safe overwrite space at the
function entry (must verify no relative jumps in first 5 bytes) - Post-update hook fires for ALL balls, not just collision pairs — you must
scan the collision list yourself to find ball-ball hits - Thread safety: the hook runs on the game thread, so blocking operations
(file I/O, sleeps) will stutter the game
Working Reference
- Pattern:
tools/<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>/collision_hook.c(hooks event dispatchers
using the same detour technique, but onDispatchCollisionEvents/TowerCollisionEvents/
ExpertCollisionEventsinstead ofBall_Update)
Approach 3: Vtable Patching
Status: Theoretical (pattern proven in ball-ai-clone-system)
Concept
The Ball vtable is stored in .rdata at a fixed address. By patching the
vtable entry for vtable[4] (the per-frame update), you can redirect all
ball updates to your custom function. Your function can wrap the original
and add collision detection logic.
Vtable Addresses
Player Ball vtable: 0x4CF314 (vtable[4] at 0x4CF324 = 0x405E00)
8-Ball vtable: 0x4CF3A0 (vtable[4] at 0x4CF3B0 = 0x408390)
Implementation
// Save original vtable[4] values
static DWORD g_orig_player_v4 = 0;
static DWORD g_orig_8ball_v4 = 0;
// Custom update wrapper
void __fastcall custom_ball_update(void *ball, void *edx_dummy) {
// Pre-update: save state
int player_idx = *(int*)((char*)ball + 0x18);
float prev_vel[3] = {
*(float*)((char*)ball + 0x170),
*(float*)((char*)ball + 0x174),
*(float*)((char*)ball + 0x178)
};
// Call original update
// NOTE: Must use the original function pointer, not vtable lookup
// (because we patched the vtable)
((void (__fastcall *)(void*))g_orig_player_v4)(ball);
// Post-update: check for velocity changes (collision signature)
float new_vel[3] = {
*(float*)((char*)ball + 0x170),
*(float*)((char*)ball + 0x174),
*(float*)((char*)ball + 0x178)
};
// Large velocity delta = collision impulse was applied
float dx = new_vel[0] - prev_vel[0];
float dy = new_vel[1] - prev_vel[1];
float dz = new_vel[2] - prev_vel[2];
float delta = sqrtf(dx*dx + dy*dy + dz*dz);
if (delta > 5.0f && player_idx >= 0) {
// Player was bumped!
// Scan collision list for details
void *physics = *(void**)((char*)ball + 0x1A4);
if (physics) {
int count = *(int*)((char*)physics + 0x1C);
// ... iterate collision entries ...
}
}
}
void install_vtable_patch(void) {
DWORD oldProtect;
// Patch player ball vtable[4]
DWORD *player_vt = (DWORD*)0x4CF324; // vtable+0x10
VirtualProtect(player_vt, 4, PAGE_READWRITE, &oldProtect);
g_orig_player_v4 = *player_vt;
*player_vt = (DWORD)custom_ball_update;
VirtualProtect(player_vt, 4, oldProtect, &oldProtect);
// Patch 8-ball vtable[4] (if needed)
DWORD *ball8_vt = (DWORD*)0x4CF3B0; // 8-ball vtable+0x10
VirtualProtect(ball8_vt, 4, PAGE_READWRITE, &oldProtect);
g_orig_8ball_v4 = *ball8_vt;
*ball8_vt = (DWORD)custom_ball_update; // or separate 8-ball handler
VirtualProtect(ball8_vt, 4, oldProtect, &oldProtect);
}
Pros
- Cleanest hook: no code modification, just data (vtable pointer swap)
- Can intercept ALL ball updates with a single patch
- Fully written in C
- Easy to uninstall (restore original vtable values)
- Can add pre/post processing around the original update
- Does not need to know the calling convention of the original function
(it's always__thiscallvia vtable dispatch)
Cons
- The vtable is in
.rdata— VirtualProtect is needed to make it writable - If the game creates balls AFTER your patch, the new balls will use the
patched vtable automatically (good) but you must ensure your wrapper
handles all ball types correctly - The wrapper must not call C library functions that disturb the FPU state
(the originalBall_Updateuses heavy x87 FPU operations) - Velocity-delta detection is an indirect signal — it can't distinguish
between a collision impulse and a catapult/trajectory boost - The vtable may be copied per-ball-instance at construction time — need
to verify whether all balls share the same vtable pointer or have copies - 8-ball AI tick (0x408390) calls Ball_Update internally — if you patch
both vtables, the 8-ball's call to Ball_Update will go through your wrapper
TWICE (once for the AI tick wrapper, once for the internal Ball_Update call).
Must handle this with a re-entrancy guard.
Working Reference
- Pattern:
ball-ai-clone-system(skillhamsterball-rereference
ball-ai-clone-system.md) — creates custom vtables for AI clones by
copying the player vtable and replacing specific slots
Approach 4: Background Polling Thread
Status: Theoretical
Concept
Run a background thread that periodically reads the ball positions from the
Scene's ball lists and performs distance-based collision detection. This
requires no code hooks at all — just memory reads.
Implementation
static DWORD WINAPI collision_poll_thread(LPVOID param) {
(void)param;
Sleep(5000); // Wait for game to fully load
void *app = *(void**)0x005341E0;
if (!app) return 1;
void *scene = *(void**)((char*)app + 0x178);
if (!scene) return 1;
// Previous positions for all balls
float prev_pos[8][3] = {0};
int prev_count = 0;
while (1) {
// Read player ball list
int p_count = *(int*)((char*)scene + 0x29D8);
void **p_balls = *(void***)((char*)scene + 0x2DE0);
// Read 8-ball list
int b_count = *(int*)((char*)scene + 0x3208);
void **b_balls = *(void***)((char*)scene + 0x3610);
// Check all player vs 8-ball pairs
for (int p = 0; p < p_count && p_balls; p++) {
void *pball = p_balls[p];
if (!pball || IsBadReadPtr(pball, 0x200)) continue;
float px = *(float*)((char*)pball + 0x164);
float py = *(float*)((char*)pball + 0x168);
float pz = *(float*)((char*)pball + 0x16C);
float pr = *(float*)((char*)pball + 0x284);
for (int b = 0; b < b_count && b_balls; b++) {
void *bball = b_balls[b];
if (!bball || IsBadReadPtr(bball, 0x200)) continue;
float bx = *(float*)((char*)bball + 0x164);
float by = *(float*)((char*)bball + 0x168);
float bz = *(float*)((char*)bball + 0x16C);
float br = *(float*)((char*)bball + 0x284);
float dx = px - bx, dy = py - by, dz = pz - bz;
float dist = sqrtf(dx*dx + dy*dy + dz*dz);
if (dist < pr + br) {
// Collision detected!
printf("Player %d hit 8-ball %d (dist=%.1f)\n",
p, b, dist);
}
}
// Also check player vs player
for (int p2 = p+1; p2 < p_count && p_balls; p2++) {
void *pball2 = p_balls[p2];
if (!pball2 || IsBadReadPtr(pball2, 0x200)) continue;
float p2x = *(float*)((char*)pball2 + 0x164);
float p2y = *(float*)((char*)pball2 + 0x168);
float p2z = *(float*)((char*)pball2 + 0x16C);
float p2r = *(float*)((char*)pball2 + 0x284);
float dx = px - p2x, dy = py - p2y, dz = pz - p2z;
float dist = sqrtf(dx*dx + dy*dy + dz*dz);
if (dist < pr + p2r) {
printf("Player %d hit Player %d (dist=%.1f)\n",
p, p2, dist);
}
}
}
Sleep(16); // ~60Hz polling
}
return 0;
}
Pros
- No code hooks whatsoever — pure memory reads
- Cannot crash the game (read-only access)
- Can detect ALL collision types (player-ball, player-player, ball-ball)
- Works with any game version (only depends on struct offsets)
- Can run at any frequency (60Hz, 30Hz, etc.)
- Can read additional ball state (velocity, radius) for richer detection
Cons
- Race condition risk: reading ball positions while the game thread is
writing them can give inconsistent data (ball moved between X and Y reads) - No collision event data: you only get proximity, not the actual
collision normal, impulse direction, or speed comparison - No pre/post state: you see the result after the physics tick, not the
collision itself — you may miss fast collisions that resolve in one frame - Radius-based detection is approximate: the game's actual collision uses
sphere-vs-triangle intersection with the collision mesh, not simple
sphere-vs-sphere distance - Thread safety:
IsBadReadPtris technically deprecated and can cause
issues; better to use SEH or VirtualQuery for safe reads - Memory overhead: polling at 60Hz with multiple balls creates CPU load
- False positives: two balls being close doesn't mean they "collided" —
they might be resting against each other. Need velocity or position-delta
to distinguish a new collision from continuous contact
Best Use Case
When you need a quick, safe detection without modifying game code at all.
Good for statistics gathering, overlay displays, or simple trigger systems.
Approach 5: Hook the Impulse Function (vtable[6])
Status: Theoretical
Concept
Instead of hooking inside Ball_Update, hook the ApplyForce function
(vtable[6]) that is called during collision response. Every time a collision
impulse is applied, your hook runs and can inspect the direction and magnitude.
Addresses
| Ball Type | vtable[6] Address | Function Name |
|---|---|---|
| Player | 0x4016F0 |
Ball_ApplyForceV2 |
| 8-Ball | 0x402650 |
Ball_ApplyForceWithMultipliers |
Implementation
// Hook signature: void __thiscall ApplyForce(void *this, float dx, float dy, float dz, float multiplier)
typedef void (__fastcall *ApplyForce_t)(void *ball, void *edx, float dx, float dy, float dz, float mult);
static ApplyForce_t g_orig_ApplyForce = NULL;
static unsigned char g_trampoline[16];
void __fastcall hook_ApplyForce(void *ball, void *edx_dummy,
float dx, float dy, float dz, float mult) {
// This is called for EVERY force application, not just collisions.
// Need to filter for collision impulses.
//
// Collision impulses have multiplier == 1.0 and are called in pairs
// (one for each ball in the collision). The direction is normalized.
//
// Input forces (from player control) have different multipliers
// (0.25 for first frame, 0.0 in tube, etc.)
if (mult == 1.0f) {
// Likely a collision impulse
int player_idx = *(int*)((char*)ball + 0x18);
float magnitude = sqrtf(dx*dx + dy*dy + dz*dz);
if (magnitude > 1.0f && player_idx >= 0) {
printf("Player %d received impulse (%.1f, %.1f, %.1f) mag=%.1f\n",
player_idx, dx, dy, dz, magnitude);
}
}
// Call original
g_orig_ApplyForce(ball, NULL, dx, dy, dz, mult);
}
Pros
- Called at the exact moment of impulse application
- Can modify the impulse direction/magnitude (custom knockback)
- Can cancel the impulse entirely (no-clip mode)
- Can distinguish between collision impulses (mult=1.0) and control forces
- Written in C with standard detour technique
Cons
- Very high call frequency:
ApplyForceis called for EVERY force, not
just collisions — player input, catapult boosts, trajectory launches,
gravity adjustments all go through this function - Hard to distinguish collision impulses: the multiplier is 1.0 for
collisions, but also for other forces. The direction is normalized for
collisions but may be arbitrary for other forces - No other-ball context: the function only receives
(this, dx, dy, dz, mult).
You don't know WHICH ball caused the collision. To find the other ball,
you'd need to scan the collision list separately - Asymmetric: only the ball receiving the force calls this function. You
can't tell if it was a player-player or player-8ball collision from the
call alone - May be called from non-collision paths: the 8-ball AI may call ApplyForce
for movement, not just collisions
Best Use Case
When you need to modify collision response (e.g., custom knockback, immunity
frames, or amplified bump force) rather than just detect it.
Approach 6: Hook Collision Event Dispatchers
Status: PROVEN — Working tool exists (tools/<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>/)
Concept
Hook the three collision dispatch functions that the game calls when the ball
hits named collision objects (walls, events, triggers). These are NOT for
ball-ball collisions, but for level geometry and event trigger collisions.
Addresses
| Address | Function | Domain |
|---|---|---|
0x40C5D0 |
DispatchCollisionEvents |
Shared base handler (all events) |
0x40DCD0 |
TowerCollisionEvents |
Race level events |
0x40E6A0 |
ExpertCollisionEvents |
Arena events |
Important Distinction
These dispatchers handle level object collisions (walls, catapults, jump
pads, trapdoors, etc.), NOT ball-ball collisions. The event name string at
collObj+0x864 identifies what was hit.
When to Use
- Detect when a player hits a specific level object (e.g., "E:JUMP")
- Log all collision events for debugging
- Filter by event name (e.g., only "E:CATAPULTBOTTOM")
When NOT to Use
- Ball-ball collision detection (these dispatchers are not called for that)
- Detecting "player got bumped by badball" (use Approach 1 or 2 instead)
Working Reference
- Source:
tools/<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>/collision_hook.c - Build:
i686-w64-mingw32-gcc -shared -o <a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>.dll collision_hook.c -Wl,--enable-stdcall-fixup - Inject:
tools/<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>/injector.exe
Approach 7: Custom Distance Check (No Hooks)
Status: Theoretical (simplest possible approach)
Concept
In your existing per-frame render hook (slot 11), read all ball positions and
perform a simple sphere-vs-sphere distance check. This requires NO additional
hooks — just memory reads in your existing render callback.
Implementation (in your mod API framework)
void OnRender() {
void* app = api->GetApp();
if (!app) return;
void* scene = *(void**)((char*)app + 0x178);
if (!scene) return;
int p_count = *(int*)((char*)scene + 0x29D8);
void** p_balls = *(void***)((char*)scene + 0x2DE0);
int b_count = *(int*)((char*)scene + 0x3208);
void** b_balls = *(void***)((char*)scene + 0x3610);
static float last_dist[4][16] = {0}; // [player][ball] previous distance
static bool in_contact[4][16] = {false};
for (int p = 0; p < p_count && p_balls; p++) {
void* pball = p_balls[p];
if (!pball) continue;
float px = *(float*)((char*)pball + 0x164);
float py = *(float*)((char*)pball + 0x168);
float pz = *(float*)((char*)pball + 0x16C);
float pr = *(float*)((char*)pball + 0x284);
for (int b = 0; b < b_count && b_balls; b++) {
void* bball = b_balls[b];
if (!bball) continue;
float bx = *(float*)((char*)bball + 0x164);
float by = *(float*)((char*)bball + 0x168);
float bz = *(float*)((char*)bball + 0x16C);
float br = *(float*)((char*)bball + 0x284);
float dx = px - bx, dy = py - by, dz = pz - bz;
float dist = sqrtf(dx*dx + dy*dy + dz*dz);
float threshold = pr + br;
bool touching = (dist < threshold);
// Only report NEW contacts (rising edge)
if (touching && !in_contact[p][b]) {
// Player p just bumped 8-ball b
DrawTextOnScreen(font, "BUMP!", 300, 50, ...);
}
in_contact[p][b] = touching;
}
}
}
Pros
- Simplest implementation — no hooks, no code caves, no vtable patching
- Runs in your existing render hook — no new threads
- Can draw visual feedback immediately (screen text, overlays)
- Safe: read-only memory access, no game state modification
- Can detect player-player and player-8ball collisions equally
Cons
- Rising-edge detection only: you detect "just started touching", but you
can't get the impulse direction, force magnitude, or collision normal - Radius is approximate: the game's actual collision uses the collision mesh
(triangles), not simple sphere-sphere. Two balls may appear to be touching
by distance but not actually collide in the game's physics - Frame-rate dependent: if two balls pass through each other in one frame
(high speed), you may miss the collision entirely - No dedup with game's collision system: the game may have already resolved
the collision (separated the balls) by the time your render hook runs
Best Use Case
Quick prototyping, visual feedback, or simple "bump counter" HUD overlays.
Approach 8: Reimplementation
Status: Partial — reimpl has basic ground collision but no ball-ball
Concept
In the Hamsterball reimplementation (reimpl/src/physics/physics.c), implement
ball-ball collision detection from scratch using sphere-sphere intersection.
Current State
The reimpl currently has:
- A single
g_ballstruct (no multi-ball support) - Basic ground collision via
get_ground_height()heightmap lookup - No ball-ball collision detection at all
- No 8-ball AI or NPC balls
What Would Be Needed
typedef struct {
Vec3 position;
Vec3 velocity;
float radius;
int player_index; // -1 = NPC, 0+ = player
bool active;
} Ball;
#define MAX_BALLS 32
static Ball balls[MAX_BALLS];
static int ball_count = 0;
void check_ball_ball_collisions(void) {
for (int i = 0; i < ball_count; i++) {
for (int j = i + 1; j < ball_count; j++) {
Ball *a = &balls[i];
Ball *b = &balls[j];
if (!a->active || !b->active) continue;
float dx = b->position.x - a->position.x;
float dy = b->position.y - a->position.y;
float dz = b->position.z - a->position.z;
float dist = sqrtf(dx*dx + dy*dy + dz*dz);
float min_dist = a->radius + b->radius;
if (dist < min_dist && dist > 0.0001f) {
// Collision detected!
float scale = (min_dist - dist) / dist * 0.5f;
// Separate balls
a->position.x -= dx * scale;
a->position.y -= dy * scale;
a->position.z -= dz * scale;
b->position.x += dx * scale;
b->position.y += dy * scale;
b->position.z += dz * scale;
// Calculate impulse direction (matching original game's 0.025 scale)
float dir_x = (a->position.x - b->position.x) * 0.025f;
float dir_y = (a->position.y - b->position.y) * 0.025f;
float dir_z = (a->position.z - b->position.z) * 0.025f;
float mag = sqrtf(dir_x*dir_x + dir_y*dir_y + dir_z*dir_z);
if (mag > 0 && mag < 3.0f) {
float s = 3.0f / mag;
dir_x *= s; dir_y *= s; dir_z *= s;
}
// Apply impulses
a->velocity.x += dir_x;
a->velocity.y += dir_y;
a->velocity.z += dir_z;
b->velocity.x -= dir_x;
b->velocity.y -= dir_y;
b->velocity.z -= dir_z;
// Fire collision callback
on_ball_collision(a, b);
}
}
}
}
Pros
- Full control over collision detection and response
- Can implement custom collision rules (e.g., team-based, power-up effects)
- No binary patching required
- Can add detailed collision events (direction, force, contact point)
Cons
- Requires multi-ball support in the reimpl first (currently single-ball)
- Requires NPC 8-ball AI to exist in the reimpl (doesn't yet)
- Must match the original game's collision feel (0.025 scale, 3.0 minimum, 0.7
speed ratio for "significant hit") - Significant development effort
Decision Matrix
| Approach | Difficulty | Reliability | Can Modify Response | Gets Other Ball | Best For |
|---|---|---|---|---|---|
| 1. Code Cave | Hard | High | Yes (at hook point) | Yes (EDI) | Production mods, scoring |
| 2. Detour Ball_Update | Medium | Medium | Pre/post only | Via collision list | Analysis, logging |
| 3. Vtable Patch | Medium | High | Yes (wrapper) | Via collision list | AI mods, custom physics |
| 4. Poll Thread | Easy | Low | No | Yes (position) | Safe detection, stats |
| 5. Hook ApplyForce | Medium | Low | Yes (impulse) | No | Custom knockback |
| 6. Event Dispatchers | Easy | High | No | No (level objects) | Level event logging |
| 7. Render Hook Dist | Easy | Medium | No | Yes (position) | Quick prototyping |
| 8. Reimpl | Hard | High | Full | Full | Long-term, custom game |
Recommendations
- Just need to know when a bump happens: Approach 7 (render hook distance check)
- Need reliable detection with logging: Approach 1 (code cave, proven in 8ball_hit_detect)
- Need to modify collision response (knockback, immunity): Approach 5 (hook ApplyForce)
- Need to intercept before physics runs: Approach 2 (detour Ball_Update)
- Building a full mod with custom AI: Approach 3 (vtable patching)
- Can't modify game code at all: Approach 4 (polling thread)
Pitfalls and Lessons Learned
1. Never Call C Functions from Hand-Assembled Code Caves
Problem: Calling log_hit(), snprintf(), or any C function from inside a
code cave in Ball_Update corrupts the FPU/stack/SEH state and crashes the game.
Root Cause: Ball_Update uses heavy x87 FPU operations. A CALL to a C
function disrupts the FPU stack alignment and may modify control words. The
PUSHAD/POPAD saves general registers but NOT the FPU state. Even
FNSAVE/FNRSTOR doesn't fully protect against SEH frame corruption.
Solution: Use the volatile flag + polling thread pattern:
- Code cave sets a
volatile DWORD g_hit_pending = player_index + 1 - A background
CreateThreadloop checksg_hit_pendingevery 50ms - All file I/O and string formatting happens on the polling thread
Source: This lesson was learned the hard way during 8ball_hit_detect v4
development. See mods/8ball_hit_detect/8ball_hit_detect.c comments.
2. Symmetric Double-Fire
Problem: Ball_Update runs for both balls in a collision pair. Your hook
fires twice per collision event.
Solution: Use pointer comparison (ESI < EDI) to count each collision once,
or track the collision ID ([EBP+0x64]) and deduplicate.
3. Calling Convention: __thiscall via __fastcall
Problem: MinGW GCC doesn't support __thiscall directly on x86.
Solution: Use __fastcall with a dummy EDX parameter:
// __thiscall(this, arg1) == __fastcall(this, dummy_edx, arg1)
void __fastcall hook_func(void *this_, void *edx_dummy, void *arg1);
Both use ECX for this, and the callee cleans the stack.
4. AthenaList Iteration is Not Thread-Safe
Problem: The collision list at PhysicsBody+0x18 uses AthenaList_NextIndex
which increments an internal counter at +0x408 that wraps at 256. Reading this
list from a background thread may return stale or inconsistent data.
Solution: Either (a) hook inside Ball_Update where the list is valid, or
(b) use position-based detection (Approach 4/7) which doesn't depend on the
collision list.
5. ball+0x2E9 is NOT a Ground/On-Surface Flag
Problem: The impact_shatter at ball+0x2E9 looks like it indicates
surface contact, but it's actually a sticky flag set by E:LIMIT (arena
boundary) collisions. It's never cleared within Ball_Update.
Solution: For ground detection, use Mesh_FindClosestCollision
(0x465D90) raycasting as documented in docs/RAYCASTING_FOR_DLL_MODS.md.
6. Verify Original Bytes Before Patching
Problem: Different game versions may have different bytes at the hook site.
Solution: Always verify the original bytes match before installing a hook:
if (memcmp(hook_addr, expected_bytes, byte_count) != 0) {
log("ERROR: Hook site bytes mismatch — wrong game version?");
return;
}
7. Velocity Fields are Force Accumulators, Not Direct Velocity
Problem: Writing directly to ball+0x170/174/178 (velocity) REPLACES the
accumulated force, killing horizontal momentum.
Solution: Use FLD/FADD/FSTP to ADD to the accumulator:
FLD DWORD [ESI+0x174] ; load current Y velocity
FADD <impulse> ; add impulse
FSTP DWORD [ESI+0x174] ; store result
8. The 8-Ball AI Tick Calls Ball_Update
The 8-ball AI tick at 0x408390 calls Ball_Update (0x405E00) at offset
0x4083BD (CALL 0x405E00). This means:
- If you hook
Ball_Update, your hook runs for both player balls and 8-balls
(because the 8-ball AI internally calls through to Ball_Update) - If you patch both vtable[4] entries, the 8-ball's internal call to Ball_Update
goes through your wrapper TWICE
Use a re-entrancy guard:
static __thread int in_update = 0;
void __fastcall hook_Ball_Update(void *ball, void *edx) {
if (in_update) {
g_orig_Ball_Update(ball, NULL);
return;
}
in_update = 1;
// ... your logic ...
g_orig_Ball_Update(ball, NULL);
// ... post-update logic ...
in_update = 0;
}
9. Font Pointer Not Available at DLL Load
Problem: If you cache App+0x318 (font pointer) at DLL load time, it will
be NULL because the font hasn't been created yet.
Solution: Read App+0x318 fresh every frame in your render hook. The
font is valid once any scene (menu or level) has loaded.
10. UI_DrawTextCentered Does NOT Auto-Center
Despite its name, 0x409C60 does not automatically center text on screen.
The "centered" refers to vertical centering within the glyph cell. You must
calculate the text width and compute the X position yourself. See
docs/UI_TEXT_ELEMENTS.md for the GetTextWidth() implementation.
References
mods/8ball_hit_detect/— Working code cave hook mod (proven)tools/<a href="#63393331680655" title="collision_hook" class="record-link ">collision_hook</a>/— Working detour hook tool for event dispatchersdocs/COLLISION_SYSTEM.md— Collision mesh, octree, and physics pipelinedocs/COLLISION_SYSTEM_DEEP.md— Deep dive on collision structsdocs/COLLISION_EVENT_SYSTEM.md— Event dispatcher chain (level/arena events)docs/BALL_OBJECT.md— Ball struct field referencedocs/BALL_UPDATE_DECOMP.md— Ball_Update decompilation annotationsdocs/RAYCASTING_FOR_DLL_MODS.md— Raycast pattern for ground/wall detectiondocs/ARENA_SCORING.md— arena scoring system (ArenaBoard, score overflow)- Skill
hamsterball-rereferenceball-ai-clone-system.md— Vtable patching for AI clones - Skill
hamsterball-rereferenceball-ball-collision-hook.md— collision hook addresses - Skill
hamsterball-rereferencehamsterball-dll-modding.md— DLL mod build patterns
🔗 Related Documents
Race Start Hooking Guide
📂 [View source on GitHub](https://github.com/evangit2/hamsterball-re/blob/master/docs/agent-knowledge/race-start-hooking.md)
Race Start Hooking Guide
How to Execute Code When a Race Starts
This document maps the complete race-start initialization pipeline and
identifies every hookable function where modders can execute custom code
at the moment a player loads into a race — after balls and objects are
instantiated, but before the countdown finishes.
All addresses, calling conventions, and field offsets were verified via
GhidraMCP decompilation and disassembly of Hamsterball.exe (V3.6.c).
Race-Start Pipeline (Call Chain)
App_Start*Race (0x4288B0 / 0x4289F0 / 0x428B20 / 0x428C50 / 0x429230)
├─ App_StartRace (0x4287C0) ← Reset old scene, free old objects
│ └─ Scene_UpdateChildren (0x466AC0) ← Destroy children of old scene
├─ Create PlayerProfile (operator_new + ctor)
└─ Tournament_AdvanceRace (0x427080) ← param_1='\x01' = show menu only
│
└─ [Player waits at TourneyMenu, timer counts up]
│
└─ TourneyMenu_Tick (0x450860) ← Timer expires
├─ (*menu->vtable[16])() ← Destroy menu
└─ Tournament_AdvanceRace (0x427080) ← param_1='\x00' = CREATE BOARD
├─ switch(race_index):
│ case 1: operator_new(0x436C) → LevelBoard_WarmUp_ctor
│ case 2: operator_new(0x644C) → LevelBoard_Beginner_ctor
│ case 3: operator_new(0x438C) → LevelBoard_Intermediate_ctor
│ ... (15 cases total)
│
│ Each ctor:
│ └─ Board_ctor (0x419030)
│ └─ Scene_CtorBase (0x457FE0)
│
├─ (*board->vtable[18])(board) ← Level-specific load (OVERRIDDEN per level)
│ │
│ │ Example: Scene_LoadLevel2 (0x40D280) — loads "levels\\level2":
│ │ ├─ MeshWorld_ctor (0x461510) ← Load .MESHWORLD geometry
│ │ ├─ CollisionLevel_ctorWithLevel (0x465080) ← Load collision data
│ │ ├─ Level_InitScene (0x40B090) ← Sound, camera, graphics setup
│ │ │ ├─ SoundChannel_Ctor
│ │ │ ├─ Level_SelectCameraProfile
│ │ │ ├─ Graphics_SetCullMode, SetProjection, SetViewportZ
│ │ │ └─ Audio_PlayMusicAtSpeed (start level music)
│ │ └─ (*board->vtable[32])(board) ← Scene_SpawnBallsAndObjects
│ │ ├─ For each player entry:
│ │ │ ├─ Look up "START%d-%d" position from hash table
│ │ │ ├─ Ball_ctor2 (0x4039E0) — create ball (0xC60 bytes)
│ │ │ │ └─ Sets ball+0x14C = 0 (controls ENABLED)
│ │ │ │ └─ Sets ball+0x284 = 27.0 (initial radius)
│ │ │ │ └─ Sets ball+0x188 = 5000.0 (placeholder max_speed)
│ │ │ │ └─ Sets ball+0x2FC = 1.0 (gravity_scale)
│ │ │ ├─ Ball_SetTrajectory (0x403850) — set spawn position
│ │ │ ├─ Override fields:
│ │ │ │ └─ ball+0x284 = 26.0 (radius)
│ │ │ │ └─ ball+0x188 = 5.0 (max_speed)
│ │ │ │ └─ ball+0x2FC = 0.5 (gravity_scale)
│ │ │ │ └─ ball+0x1A0 = 1.05 (speed multiplier)
│ │ │ └─ AthenaList_Append(Scene+0x29D4, ball)
│ │ ├─ Scan SAFESPOT/SAFEPOS entries
│ │ ├─ CreateBadBall (if demo/tournament mode)
│ │ ├─ CreateMouseTrap (if demo/tournament mode)
│ │ ├─ CreateSecretObjects
│ │ ├─ Scene_CreateFlags (0x40C0F0)
│ │ ├─ Scene_CreateSigns (0x40C270)
│ │ └─ Scene_CreateDynamicObjects (0x40C430)
│
├─ Scene_AddObject (0x469990) — add board to scene tree
└─ Set up player scores (App+0x5E8, +0x688, +0x728, +0x7C8)
[Next frame: game loop begins]
Scene_Update (0x419C00) / ArenaBoard_Update (0x421FE0) ← vtable[1]
├─ Frame counter increment (Scene+0xD88)
├─ Demo timer check
├─ ESC / pause check
├─ Ball position propagation (Ball_SetTargetPos)
├─ Gear path follow
├─ ToggleTimer_Tick
├─ Camera shake decay
├─ Scene object update + render (vtable[4] / vtable[0])
└─ Physics pipeline (gated by ball+0x14C):
ball_count = AthenaList_GetSize(Scene+0x362C)
if (ball_count == 1):
ball = *(App + 0x5DC) ← current physics ball pointer
if (ball+0x14C != 0): ← controls disabled
SKIP vtable[19-22] ← skip ALL physics + countdown
[else: always run vtable[19-22]]
├─ vtable[19] (0x4C) = Scene_HandleRaceEnd (0x41B130) ← 3-2-1 countdown display
│ ├─ If Scene+0x3A4C != 0: skip (countdown already done)
│ ├─ If Scene+0x8B4 != 0: skip
│ ├─ If Scene+0x3620 <= 25: skip (wait 25 frames before starting)
│ ├─ Phase 0: display "3" texture (App+0x394), increment timer
│ ├─ Phase 1: display "2" texture (App+0x398), increment timer
│ ├─ Phase 2: display "1" texture (App+0x39C), play sound
│ └─ Phase 3: set Scene+0x3A4C = 1 (countdown done)
│ └─ Start race timers, iterate balls
├─ vtable[20] (0x50) = Scene_UpdateBallsAndState (0x41B540)
│ └─ For each ball: Ball_SetCamera + Ball_Update (vtable[4])
├─ vtable[21] (0x54) = NoOp (0x40A040)
├─ vtable[22] (0x58) = Scene_ProcessRaceEnd (0x41A540) ← race-end handler
└─ For each object: vtable[31] (0x7C) = Scene_vmethod31 (camera setup)
Two Separate Countdown Systems
1. Pre-Race 3-2-1 Countdown (Scene_HandleRaceEnd, vtable[19])
This is the visual "3, 2, 1" countdown displayed at the start of each race.
- phase: Scene+0x3A50 (int) — 0="3", 1="2", 2="1", 3=done
- phase_timer: Scene+0x3A54 (float) — accumulates time per phase
- countdown_done: Scene+0x3A4C (byte) — 1 = countdown finished
- sound_played: Scene+0x3A58 (byte) — prevents double-sound in phase 2
- frame_counter: Scene+0x3620 (int) — must exceed 25 (0x19) before countdown starts
- race_timer: Scene+0x3624 (float) — decrements during race
- skip_flag: Scene+0x8B4 (int) — non-zero = skip countdown entirely
Key behavior: The 3-2-1 countdown does NOT freeze the ball. Ball+0x14C is
0 (enabled) from Ball_ctor2. The ball can move during the countdown display.
The countdown is purely cosmetic — it displays numbers and plays sounds,
then sets Scene+0x3A4C=1 and starts race timers.
The 25-frame delay (Scene+0x3620 > 25) before the countdown starts gives the
game ~0.4 seconds (at 60fps) to settle after level load before showing numbers.
2. SinkPlatform Countdown (Scene_StartCountdown, 0x437130)
Called when a ball touches a "DN:SINKPLATFORM" object. This is a
level-specific mechanism, NOT the universal pre-race countdown.
- active: Scene+0x10F1 (byte) — 1 = countdown active
- flag2: Scene+0x10F2 (byte) — second gate flag
- timer: Scene+0x10F4 (int) — 400 (0x190) normal, 50 (0x32) for AI/demo
- ball: Scene+0x10F8 (ptr) — ball being frozen
Key behavior: Sets ball+0x14C=1 (freezes ball — disables input and
physics). Restores ball+0x2FC=1.0 (gravity_scale back to 1.0 from 0.5).
The timer (Scene+0x10F4) is set to 400 but no function in the analyzed
codebase decrements it — it may be processed by a function Ghidra hasn't
identified, or it may be a dead feature that was replaced by the
Scene_HandleRaceEnd phase system.
Callers of Scene_StartCountdown: SinkPlatformArenaCollisionEvents (0x413BD0),
and various level-specific collision handlers at 0x413FA0, 0x4143AE,
0x414660, 0x414ED9, 0x4151C0, 0x4155B6, 0x41624F, 0x417620, 0x417F08,
0x41873E.
Ball+0x14C (Controls Disabled Flag)
This byte flag gates the entire physics pipeline in Scene_Update and the
player input path in Ball_Update.
- Ball_ctor2 (0x403D1D): Sets to 0 (enabled) at ball creation
- Scene_StartCountdown (0x43717B): Sets to 1 (disabled) on SinkPlatform touch
- Scene_HandleRaceEnd (0x41B40D): Sets to 1 (disabled) when race timer expires
- ArenaBoard_Update (0x421FE0): Sets to 1 (disabled) for all balls at arena end
Nobody clears ball+0x14C back to 0 after it's set to 1. The ball is
frozen permanently until respawn (which creates a new ball via Ball_ctor2).
Scene_Update Physics Gate
; At 0x419EE4-0x419F04 in Scene_Update (0x419C00)
00419ee4: MOV ECX, EBX ; EBX = Scene+0x362C (start entries list)
00419ee6: CALL 0x004536a0 ; AthenaList_GetSize(Scene+0x362C)
00419eeb: CMP EAX, 0x1 ; ball_count == 1?
00419eee: JNZ 0x00419f06 ; If != 1: skip gate, run physics unconditionally
00419ef0: MOV EAX, [ESI + 0x878] ; EAX = App (board+0x878)
00419ef6: MOV ECX, [EAX + 0x5dc] ; ECX = App+0x5DC (current physics ball POINTER)
00419efc: MOV AL, [ECX + 0x14c] ; AL = controls_disabled
00419f02: TEST AL, AL
00419f04: JNZ 0x00419f84 ; If disabled: SKIP vtable[19-22] (all physics)
The gate only applies when there is exactly one ball (single-player). In
multiplayer, physics always runs. App+0x5DC is a ball pointer (not a
count), set to the current physics ball.
Ball_Update Input Gate
; At 0x4060A1 in Ball_Update (0x405E00)
004060a1: MOV AL, byte ptr [ESI + 0x14c] ; AL = controls_disabled
004060a7: TEST AL, AL
004060a9: JNZ 0x004082C9 ; If disabled: jump to RETURN (skip input)
Hook Points (MinHook / DLL Detour)
Hook 1: Scene_SpawnBallsAndObjects — RECOMMENDED
Address: 0x41C5B0 (vtable[32], shared across ALL levels)
Calling convention: __fastcall (ECX = board). Plain RET (no stack
cleanup). No additional parameters.
Timing: After all balls and objects are created. This is the earliest
point where balls exist. Level geometry, collision, camera, and sound are
already initialized (done by vtable[18] which calls this internally at the
end).
Why this is the best hook:
- Universal: vtable[32] = 0x41C5B0 in every level subclass vtable (verified)
- Complete: everything is initialized (balls, objects, camera, sound)
- Pre-update: no physics tick has run yet
- Pre-countdown: the 3-2-1 countdown hasn't started (needs 25 frames first)
- Board pointer is in ECX (thiscall this) — directly accessible
- Clean function boundary: detour entry, call original, run your code, return
typedef void (__fastcall *SpawnBallsFn)(void* board);
SpawnBallsFn Orig_SpawnBalls = NULL;
void __fastcall Hooked_SpawnBallsAndObjects(void* board) {
Orig_SpawnBalls(board);
// At this point:
// - All balls are spawned (ball_list at board+0x29D4)
// - All objects are created (flags, signs, dynamic objects)
// - Camera and sound are configured
// - No physics tick has run yet
// - Ball+0x14C = 0 (controls enabled)
// - 3-2-1 countdown hasn't started (needs 25 frames)
int ball_count = *(int*)((char*)board + 0x29D8);
if (ball_count < 1) return;
int** ball_array = *(int***)((char*)board + 0x2DE0);
if (!ball_array || !*ball_array) return;
for (int i = 0; i < ball_count; i++) {
int* ball = (*ball_array)[i];
if (!ball || IsBadReadPtr(ball, 0x20)) continue;
float x = *(float*)((char*)ball + 0x164);
float y = *(float*)((char*)ball + 0x168);
float z = *(float*)((char*)ball + 0x16C);
// Modify ball state here
// *(float*)((char*)ball + 0x284) = 30.0f; // radius
// *(float*)((char*)ball + 0x188) = 7.0f; // max_speed
}
}
// Install:
// MH_CreateHook((LPVOID)0x41C5B0, &Hooked_SpawnBallsAndObjects, (LPVOID*)&Orig_SpawnBalls);
Hook 2: Tournament_AdvanceRace — UNIVERSAL ENTRY
Address: 0x427080
Calling convention: __thiscall (ECX = tournament profile,
[ESP+4] = char param_1). RET 0x4 (callee cleans 1 stack param).
Timing: The board is created inside this function (when param_1=0). To
access the board, call the original first, then read [this+0xC].
Parameter:
param_1 = '\0'(0): Create board — loads level, spawns ballsparam_1 = '\x01'(1): Show tourney menu — stores scores, no board created
Only hook fires on the param_1=0 path. Check param_1 before running your code.
typedef void (__thiscall *AdvanceRaceFn)(void* profile, char param_1);
AdvanceRaceFn Orig_AdvanceRace = NULL;
void __thiscall Hooked_AdvanceRace(void* profile, char param_1) {
Orig_AdvanceRace(profile, param_1);
// Only run when a board was actually created
if (param_1 != 0) return;
// Board pointer is stored at profile+0x0C after creation
int* board = *(int**)((char*)profile + 0x0C);
if (!board || IsBadReadPtr(board, 0x100)) return;
// Ball list
int ball_count = *(int*)((char*)board + 0x29D8);
if (ball_count < 1) return;
int** ball_array = *(int***)((char*)board + 0x2DE0);
if (!ball_array || !*ball_array) return;
int* ball = (*ball_array)[0];
if (!ball || IsBadReadPtr(ball, 0x20)) return;
// Your onRaceStart code here
}
// Install:
// MH_CreateHook((LPVOID)0x427080, &Hooked_AdvanceRace, (LPVOID*)&Orig_AdvanceRace);
Important: The doc's earlier version claimed that EAX = board pointer at
address 0x4273E3 (after the vtable[18] call). This is WRONG — EAX is
clobbered by the vtable[18] call at 0x4273E0. The board pointer is stored
at [ESI+0x0C] (profile+0x0C) and loaded into EAX at 0x4273E6. When using
MinHook, you don't need to worry about register state — just detour the
function entry and read profile+0x0C after calling the original.
Hook 3: App_StartRace — EARLIEST
Address: 0x4287C0
Calling convention: __thiscall (ECX = App, [ESP+4] = int flag).
RET 0x4 (callee cleans 1 stack param). The flag is always 1 in all
observed callers (App_StartTournamentRace, App_StartPracticeRace,
Scene_ProcessRaceEnd).
Timing: Before scene reset. Old objects are still alive. The new level
hasn't been loaded yet.
Use case: When you need to run code BEFORE the old scene is destroyed
(e.g., save data from the previous race). Too early for accessing new balls.
typedef void (__thiscall *StartRaceFn)(void* app, int flag);
StartRaceFn Orig_StartRace = NULL;
void __thiscall Hooked_StartRace(void* app, int flag) {
// Old scene is still alive at App+0x178
int* old_scene = *(int**)((char*)app + 0x178);
// Save data from previous race here...
Orig_StartRace(app, flag);
// After this: old scene destroyed, but new board not yet created
}
// Install:
// MH_CreateHook((LPVOID)0x4287C0, &Hooked_StartRace, (LPVOID*)&Orig_StartRace);
Hook 4: Level_InitScene — AFTER GEOMETRY, BEFORE BALLS
Address: 0x40B090 (base vtable[18])
Calling convention: __fastcall (ECX = board). Plain RET.
Timing: After MeshWorld_ctor and CollisionLevel_ctorWithLevel have
loaded geometry and collision data. Sound, camera, and graphics are set up
here. Balls have NOT been spawned yet (vtable[32] hasn't run).
Note: This is the BASE vtable[18]. Each level subclass overrides
vtable[18] with its own loader (e.g., Scene_LoadLevel2 at 0x40D280) which
internally calls MeshWorld_ctor → CollisionLevel_ctorWithLevel →
Level_InitScene → vtable[32]. Hooking Level_InitScene directly catches
the moment after camera/sound setup but before balls exist.
typedef void (__fastcall *InitSceneFn)(int* board);
InitSceneFn Orig_InitScene = NULL;
void __fastcall Hooked_InitScene(int* board) {
Orig_InitScene(board);
// Geometry loaded, collision loaded, camera/sound set up
// Balls NOT yet spawned (vtable[32] hasn't run)
// Good for: modifying camera, changing level music, etc.
}
Hook 5: Ball_ctor2 — AT BALL CREATION
Address: 0x4039E0
Calling convention: __thiscall (ECX = allocated memory,
[ESP+4] = scene pointer). RET 0x4.
Timing: During Scene_SpawnBallsAndObjects, for each player entry. The
ball is being constructed — fields get default values here, then
SpawnBalls overrides some of them (radius, max_speed, gravity_scale).
Use case: When you need to modify ball fields at the earliest possible
point. Note that SpawnBalls will override radius (27→26), max_speed
(5000→5), and gravity_scale (1.0→0.5) AFTER ctor returns.
typedef void* (__thiscall *BallCtorFn)(void* alloc, int scene);
BallCtorFn Orig_BallCtor = NULL;
void* __thiscall Hooked_BallCtor(void* alloc, int scene) {
void* ball = Orig_BallCtor(alloc, scene);
// Ball is constructed with default values
// Fields will be overridden by SpawnBalls after this returns
return ball;
}
Hook 6: Ball_Update — FIRST PHYSICS TICK
Address: 0x405E00 (vtable[4])
Calling convention: __thiscall (ECX = ball). Plain RET.
Timing: First physics tick after level load. The 3-2-1 countdown has
started (but ball+0x14C=0, so input is enabled). This runs EVERY FRAME for
EVERY BALL, so use a one-shot flag.
typedef void (__thiscall *BallUpdateFn)(void* ball);
BallUpdateFn Orig_BallUpdate = NULL;
static bool race_started = false;
void __thiscall Hooked_BallUpdate(void* ball) {
if (!race_started) {
race_started = true;
// First ball update after level load
// ball+0x14C = 0 (controls enabled)
// Countdown is displaying but ball can move
}
Orig_BallUpdate(ball);
}
Hook 7: Scene_HandleRaceEnd — COUNTDOWN START
Address: 0x41B130 (vtable[19])
Calling convention: __fastcall (ECX = board). Plain RET.
Timing: First call is 25 frames after level load. The 3-2-1 countdown
display begins here. Also called every frame during the race (handles
race-end when timer expires).
Use case: When you need to run code exactly when the countdown starts
displaying (not before, not after). Check Scene+0x3A4C to distinguish
countdown-start from race-end.
Hook 8: Scene_AddObject — BOARD ADDED TO SCENE TREE
Address: 0x469990
Calling convention: __thiscall (ECX = scene object list,
[ESP+4] = object to add). RET 0x4.
Timing: Called by Tournament_AdvanceRace right after vtable[18] returns.
The board has been fully initialized (geometry, balls, objects) and is
being added to the scene tree for rendering.
Use case: When you need to intercept the moment the board becomes
visible to the scene tree. The added object is the board itself.
Hook 9: Scene_SetRaceActive — RACE ACTIVE FLAG
Address: 0x4366E0
Calling convention: __fastcall (ECX = board). Plain RET.
Timing: Sets App+0x10EC = 1. Called from many places (62 xrefs) —
NOT just at race start. Called whenever the game transitions to "race
active" state.
Use case: Not recommended for race-start detection (too many callers,
fires in many contexts). Included here for completeness.
Complete Race-Start Timeline
Frame 0: App_Start*Race → App_StartRace → reset old scene
Tournament_AdvanceRace (show menu, param_1=1)
[OR] Tournament_AdvanceRace (create board, param_1=0)
Frame 0+: Tournament_AdvanceRace (param_1=0) creates board:
switch(race_index) → level-specific Board ctor → Board_ctor
(*board->vtable[18])(board):
[Level-specific override, e.g. Scene_LoadLevel2]:
MeshWorld_ctor → CollisionLevel_ctorWithLevel
→ Level_InitScene (sound/camera/graphics)
→ (*board->vtable[32])(board) = Scene_SpawnBallsAndObjects:
Ball_ctor2 (0x14C=0, radius=27, max_speed=5000, gravity=1.0)
Ball_SetTrajectory (spawn position)
Override: radius=26, max_speed=5, gravity=0.5
AthenaList_Append(Scene+0x29D4, ball)
CreateBadBall, CreateMouseTrap, CreateFlags, CreateSigns
CreateDynamicObjects
Scene_AddObject (board → scene tree)
Set up player scores
↑ HOOK 1 (Scene_SpawnBallsAndObjects): balls just spawned ← BEST
↑ HOOK 2 (Tournament_AdvanceRace): board at profile+0x0C
↑ HOOK 4 (Level_InitScene): geometry loaded, no balls yet
↑ HOOK 5 (Ball_ctor2): ball being constructed
↑ HOOK 8 (Scene_AddObject): board added to scene tree
Frame 1: Scene_Update (vtable[1]):
AthenaList_GetSize(Scene+0x362C) → ball_count
if (ball_count==1 && ball+0x14C!=0): skip physics
else:
vtable[19] = Scene_HandleRaceEnd:
Scene+0x3620 = 1 (frame counter, < 25 → skip countdown)
vtable[20] = Scene_UpdateBallsAndState:
Ball_Update (vtable[4]) — first physics tick
↑ HOOK 6 (Ball_Update entry): first physics tick
↑ HOOK 7 (Scene_HandleRaceEnd): called but countdown skipped
Frame 2-25: Scene_Update repeats. Countdown not yet displayed.
Ball can move freely (ball+0x14C=0).
Frame 26: Scene_HandleRaceEnd:
Scene+0x3620 > 25 → start countdown
Phase 0: display "3" (App+0x394 texture)
Scene+0x3A50 = 0
↑ HOOK 7 (Scene_HandleRaceEnd): countdown display starts
Frame ~150: Phase 1: display "2" (App+0x398 texture)
Frame ~275: Phase 2: display "1" (App+0x39C texture), play sound
Frame ~400: Phase 3: Scene+0x3A4C = 1 (countdown done)
Start race timers, iterate balls
Scene Vtable Map (vtable base at 0x4D0260)
All entries verified by reading raw vtable bytes from memory.
- [0] (0x00): 0x425020 — Board_dtor (destructor)
- [1] (0x04): 0x419C00 — Scene_Update (main game tick)
- [2] (0x08): 0x41A2E0 — Scene_SetCamera (camera setup per ball)
- [18] (0x48): 0x40B090 — Level_InitScene (BASE: sound, camera, graphics)
- [19] (0x4C): 0x41B130 — Scene_HandleRaceEnd (3-2-1 countdown + race-end)
- [20] (0x50): 0x41B540 — Scene_UpdateBallsAndState (ball physics update)
- [21] (0x54): 0x40A040 — NoOp (unused slot)
- [22] (0x58): 0x41A540 — Scene_ProcessRaceEnd (race-end menu/results)
- [27] (0x6C): 0x41B710 — Scene_RenderScoreHUD (render countdown numbers + HUD)
- [28] (0x70): 0x41BFD0 — Scene_RenderTimerHUD (render race timer)
- [31] (0x7C): 0x41AC70 — Scene_vmethod31 (per-ball camera + render setup)
- [32] (0x80): 0x41C5B0 — Scene_SpawnBallsAndObjects (spawn balls + all objects)
- [33] (0x84): 0x419750 — Scene_method33
vtable[18] is OVERRIDDEN by each level subclass. The base vtable[18]
(0x40B090 = Level_InitScene) is what the BASE class uses. Each level
subclass assigns its own vtable with a different vtable[18] that loads
the level-specific .MESHWORLD file. These overrides internally call
MeshWorld_ctor → CollisionLevel_ctorWithLevel → Level_InitScene →
vtable[32].
vtable[32] is shared across ALL levels (0x41C5B0 in every subclass
vtable, verified by reading 4 different level vtables).
Example level vtable[18] overrides (verified from vtable memory reads):
- L1 WarmUp (0x4D04A8): vtable[18] = 0x40D1C0 — loads "levels\level1"
- L2 Cascade (0x4D1098): vtable[18] = 0x4110D0 — loads "levels\levelcascade"
- L3 Intermediate (0x4D05A0): vtable[18] = 0x40D280 — loads "levels\level2"
- L4 Dizzy (0x4D0890): vtable[18] = 0x40D390 — loads "levels\level3"
Key Offsets Summary
Ball (0xC60 bytes, allocated by operator_new in Scene_SpawnBallsAndObjects)
| Offset | Type | Name | Set By | Value |
|---|---|---|---|---|
| +0x14C | byte | controls_disabled | Ball_ctor2 (0) | 0 |
| +0x164 | float | pos_x | Scene_SpawnBallsAndObjects | START position |
| +0x168 | float | pos_y | Scene_SpawnBallsAndObjects | START position |
| +0x16C | float | pos_z | Scene_SpawnBallsAndObjects | START position |
| +0x18 | int | player_id | Ball_ctor2 | -1 = AI/demo, 0+ = player |
| +0x188 | float | max_speed | Ball_ctor2 → SpawnBalls | ctor=5000.0, SpawnBalls=5.0 |
| +0x1A0 | float | speed_multiplier | Scene_SpawnBallsAndObjects | 1.05 |
| +0x27C | float | friction_scale | Scene_SpawnBallsAndObjects | 0.1 |
| +0x284 | float | radius | Ball_ctor2 → SpawnBalls | ctor=27.0, SpawnBalls=26.0 |
| +0x2F8 | byte | is_falling | Ball_ctor2 / SpawnBalls | 0 |
| +0x2FC | float | gravity_scale | Ball_ctor2 → SpawnBalls | ctor=1.0, SpawnBalls=0.5 |
| +0x310 | byte | has_target_pos | Ball_ctor2 | 1 |
| +0x769 | byte | active_flag | Scene_SpawnBallsAndObjects | 1 |
Scene/Board (variable size, 0x436C–0x6498 depending on level)
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x878 | ptr | app | App object pointer |
| +0x184 | ptr | scene_object_list | AthenaList of scene objects |
| +0x29D4 | AthenaList | ball_list | Main ball list (player balls) |
| +0x29D8 | int | ball_count | Number of balls in ball_list |
| +0x2DE0 | ptr | ball_array | Pointer to ball pointer array |
| +0x3620 | int | frame_counter | Incremented each Scene_Update |
| +0x3624 | float | race_timer | Decrements during race |
| +0x362C | AthenaList | start_entries_list | Start position entries (used for ball_count gate) |
| +0x3A4C | byte | countdown_done | 1 = 3-2-1 countdown finished |
| +0x3A50 | int | countdown_phase | 0="3", 1="2", 2="1" |
| +0x3A54 | float | countdown_phase_timer | Time in current phase |
| +0x3A58 | byte | countdown_sound_played | Prevents double-sound |
| +0x8B4 | int | countdown_skip_flag | Non-zero = skip countdown |
| +0x10F1 | byte | sink_countdown_active | SinkPlatform countdown |
| +0x10F4 | int | sink_countdown_timer | 400 normal, 50 AI/demo |
| +0x10F8 | ptr | sink_countdown_ball | Ball being frozen by SinkPlatform |
App (global at 0x5341E0)
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x174 | ptr | gfx_device | Graphics device (D3D) |
| +0x178 | ptr | sound_device | SoundDevice (SoundDevice_UpdateChannels) — NOT scene! |
| +0x17C | ptr | music_device | MusicDevice (MusicDevice_FadeAll) |
| +0x180 | ptr | input_device | InputDevice (InputDevice_PollAndRelease) |
| +0x184 | ptr | meshworld | MeshWorld / scene manager (contains board in its list) |
| +0x220 | ptr | player_profile | Current PlayerProfile (profile+0xC = board) |
| +0x237 | byte | is_arena | 1 = arena mode (2P), 0 = tournament/practice |
| +0x23C | int | difficulty | 0=Pipsqueak, 1=Normal, 2=Frenzied |
| +0x5DC | ptr | physics_ball | Current physics ball pointer (read-only, never written in code) |
| +0x10EC | int | race_active | Set to 1 by Scene_SetRaceActive |
WARNING: Previous versions of this doc claimed App+0x178 = "scene". This is
WRONG. App+0x178 is the SoundDevice, verified via App_FrameUpdate (0x46C170)
which calls SoundDevice_UpdateChannels(*(App+0x178)). The scene/board is
NOT stored at any single App field — see "Getting the Scene Pointer" below.
Complete Calling Convention Reference
All verified via disassembly (RET instruction inspection).
| Function | Address | Convention | RET | Params |
|---|---|---|---|---|
| App_StartRace | 0x4287C0 | __thiscall | RET 0x4 | ECX=App, [ESP+4]=int flag (always 1) |
| App_StartTournamentRace | 0x4288B0 | __fastcall | RET | ECX=App |
| App_StartTourneyRace | 0x4289F0 | __fastcall | RET | ECX=App |
| App_StartMPRace | 0x428B20 | __thiscall | RET 0x4 | ECX=App, [ESP+4]=? |
| App_StartPracticeRace | 0x428C50 | __thiscall | RET 0x4 | ECX=App, [ESP+4]=? |
| App_Start2PRace | 0x429230 | __thiscall | RET 0x4 | ECX=App, [ESP+4]=? |
| Tournament_AdvanceRace | 0x427080 | __thiscall | RET 0x4 | ECX=profile, [ESP+4]=char param_1 |
| Scene_SpawnBallsAndObjects | 0x41C5B0 | __fastcall | RET | ECX=board |
| Scene_Update | 0x419C00 | __fastcall | RET | ECX=board |
| Scene_HandleRaceEnd | 0x41B130 | __fastcall | RET | ECX=board |
| Scene_UpdateBallsAndState | 0x41B540 | __fastcall | RET | ECX=board |
| Scene_ProcessRaceEnd | 0x41A540 | __fastcall | RET | ECX=board |
| Scene_StartCountdown | 0x437130 | __thiscall | RET 0x4 | ECX=scene, [ESP+4]=ball |
| Ball_Update | 0x405E00 | __thiscall | RET | ECX=ball |
| Ball_ctor2 | 0x4039E0 | __thiscall | RET 0x4 | ECX=alloc, [ESP+4]=scene |
| Ball_SetTrajectory | 0x403850 | __thiscall | RET 0x14 | ECX=ball, 5 stack floats |
| Level_InitScene | 0x40B090 | __fastcall | RET | ECX=board |
| Scene_AddObject | 0x469990 | __thiscall | RET 0x4 | ECX=list, [ESP+4]=obj |
| MeshWorld_ctor | 0x461510 | __thiscall | RET 0x8 | ECX=alloc, [ESP+4]=d3dDevice, [ESP+8]=path |
| CollisionLevel_ctorWithLevel | 0x465080 | __thiscall | RET 0x4 | ECX=alloc, [ESP+4]=sourceMesh |
| Board_ctor | 0x419030 | __thiscall | RET 0x4 | ECX=alloc, [ESP+4]=App |
| Scene_SetRaceActive | 0x4366E0 | __fastcall | RET | ECX=board |
Getting the Scene Pointer
There is NO global variable that directly holds the current board/scene
pointer. The board is only accessible through indirect chains or by hooking
a function that receives it as a parameter. Verified by searching the entire
binary for writes to any dedicated "current scene" field — none exist.
Method 1: Hook Scene_Update + Global (RECOMMENDED for MinHook)
Hook Scene_Update (0x419C00), save ECX to a global, and compare with the
previous frame to detect level changes.
Why Scene_Update (0x419C00) and NOT Ball_Update (0x405E00):
- Scene_Update is called for ALL levels, ALL modes, EVERY frame
- vtable[1] is overridden by some levels (Intermediate=0x41CC90, Dizzy=0x41D510,
ArenaBoard=0x421FE0), but ALL overridesCALL 0x419C00directly — so the
MinHook at 0x419C00 fires for every level - Ball_Update (0x405E00) is NOT called for player balls in race mode!
vtable[4] (Ball_AI_ChaseNearest, 0x408390) checksball+0xC74(AI flag)
andApp+0x237(is_arena). If neither is set: skips Ball_Update and calls
vtable[5] instead. Ball_Update only fires for AI balls or arena mode. - Scene_Update is
__fastcall(ECX = board), plainRET— clean hooking
Why Scene_SpawnBallsAndObjects (0x41C5B0) may not have fired:
The function IS called for every level (verified via vtable analysis + disasm
of 4 level loaders). ASLR is disabled, address is correct. If the hook doesn't
fire, likely causes are: MinHook initialization failure (check MH_CreateHook
return code), hook not enabled (MH_EnableHook not called), or output
mechanism not working (use MessageBoxA for debugging, not fopen).
static int* g_scene = NULL;
static int* g_prev_scene = NULL;
static bool g_level_just_started = false;
typedef void (__fastcall *SceneUpdateFn)(int* board);
SceneUpdateFn Orig_SceneUpdate;
void __fastcall Hooked_SceneUpdate(int* board) {
g_scene = board;
if (g_scene != g_prev_scene) {
// Level just changed (or first frame)
g_level_just_started = true;
g_prev_scene = g_scene;
} else {
g_level_just_started = false;
}
Orig_SceneUpdate(board);
}
// Install: MH_CreateHook((LPVOID)0x419C00, &Hooked_SceneUpdate, (LPVOID*)&Orig_SceneUpdate);
// Then anywhere: if (g_level_just_started) { ... onLevelStart code ... }
This gives you both a persistent scene pointer AND a level-change callback.
Method 2: App Global → PlayerProfile → Board
Read the App global at 0x5341E0, then follow the profile chain.
int* app = *(int**)0x5341E0;
if (!app || IsBadReadPtr(app, 0x300)) return NULL;
int* profile = *(int**)((char*)app + 0x220);
if (!profile || IsBadReadPtr(profile, 0x20)) return NULL;
int* board = *(int**)((char*)profile + 0x0C);
if (!board || IsBadReadPtr(board, 0x100)) return NULL;
// board is now the scene/board pointer
Caveat: profile+0xC is set to NULL at the START of Tournament_AdvanceRace
(before creating the new board). It's only valid during active gameplay.
Method 3: App Global → MeshWorld → Items Array → Board
Read the scene manager (MeshWorld) and iterate its object list.
int* app = *(int**)0x5341E0;
if (!app || IsBadReadPtr(app, 0x200)) return NULL;
int* meshworld = *(int**)((char*)app + 0x184);
if (!meshworld || IsBadReadPtr(meshworld, 0x500)) return NULL;
// MeshWorld+0x40C = pointer to items array (AthenaList internal)
int* items = *(int**)((char*)meshworld + 0x40C);
if (!items || IsBadReadPtr(items, 0x10)) return NULL;
// First item = board (usually the only object in the list)
int* board = *(int**)items;
if (!board || IsBadReadPtr(board, 0x100)) return NULL;
// board is now the scene/board pointer
Verified: GameUpdate (0x469CF0) uses this exact path:
MOV EAX, [EBP+0x40C]; MOV ECX, [EAX] to get the first board from the list.
Why App+0x178 Does NOT Work
Previous docs claimed App+0x178 = "scene pointer". This is wrong.
App_FrameUpdate (0x46C170) proves it:
// From App_FrameUpdate decompilation:
if (*(int*)(param_1 + 0x178) != 0)
SoundDevice_UpdateChannels(*(int*)(param_1 + 0x178)); // App+0x178 = SOUND DEVICE
GameUpdate(*(int*)(param_1 + 0x184)); // App+0x184 = scene manager
App+0x178 is the SoundDevice, not the scene. The confusion arose because
App_StartRace calls Scene_UpdateChildren(*(App+0x178)), and Scene_UpdateChildren
was misidentified as a scene function — it's actually a sound channel cleanup call.
Pitfalls
1. Ghidra Function Name Confusion
- Scene_HandleRaceEnd (0x41B130, vtable[19]) = Pre-race 3-2-1 countdown
display, NOT just race-end. It also handles post-race results. - Scene_ProcessRaceEnd (0x41A540, vtable[22]) = Race-end menu/results
handler, NOT the pre-race countdown. Name is misleading. - Scene_StartCountdown (0x437130) = SinkPlatform freeze countdown,
NOT the universal pre-race 3-2-1 countdown.
2. Ball+0x14C Is NOT the Countdown Gate
The 3-2-1 countdown does NOT set ball+0x14C. The ball is free to move during
the countdown. Ball+0x14C is only set by:
- SinkPlatform collision (Scene_StartCountdown)
- Race timer expiry (Scene_HandleRaceEnd post-countdown)
- Arena end (ArenaBoard_Update)
3. Scene+0x10F4 Timer Is Dead Code
The SinkPlatform countdown timer (Scene+0x10F4=400) is set by
Scene_StartCountdown but no function in the analyzed codebase decrements it.
Verified by searching for 0x10F4 references in Scene_Update,
Scene_HandleRaceEnd, Scene_UpdateBallsAndState, and Scene_ProcessRaceEnd —
none reference it. It may be a legacy feature replaced by the
Scene_HandleRaceEnd phase system.
4. Multiple Scene Vtables
There are 15+ scene subclass vtables (one per level type), each at a different
address. vtable[18] is OVERRIDDEN per level (each loads a different
.MESHWORLD file), but vtable[32] (Scene_SpawnBallsAndObjects) is shared.
For universal hooks, target shared functions (0x41C5B0, 0x427080) rather
than per-level functions.
5. Arena vs Race Initialization
Arena levels use ArenaBoard_*_Init functions (e.g.
ArenaLevel_WarmUp_Init at 0x413C20) instead of Scene_LoadLevel*.
These also call vtable[32] = Scene_SpawnBallsAndObjects, so the same
hook works for both arenas and races.
6. Tournament_AdvanceRace param_1
Tournament_AdvanceRace(profile, param_1):
param_1 = '\0'(0): Create board — loads level, spawns balls, adds to sceneparam_1 = '\x01'(1): Show tourney menu — stores scores, creates TourneyMenuparam_1 = '\x01'withApp+0x237set: CreatesTourneyMenu_CreateBoard(arena)
Only the param_1 = '\0' path creates the board. Hook 1 fires on this path.
7. Ball Field Overrides
Ball_ctor2 sets initial values, but Scene_SpawnBallsAndObjects overrides
several fields AFTER Ball_ctor2 returns. If you hook Ball_ctor2 to modify
a field, SpawnBalls may overwrite your change. Hook
Scene_SpawnBallsAndObjects instead to modify fields after all overrides.
8. App_StartRace Has a Stack Parameter
App_StartRace (0x4287C0) has RET 0x4, meaning it cleans 1 stack
parameter. All callers push 1 before calling. Ghidra labels it
__fastcall but it's effectively __thiscall (ECX=App). When hooking
with MinHook, declare it as __thiscall with 2 params.
Document verified via GhidraMCP decompilation and disassembly of
Hamsterball.exe (V3.6.c, md5=7d25019366b8d7f55906325bd630d7fe). All
function addresses, vtable layouts, calling conventions (via RET
instruction inspection), and field offsets cross-referenced against
raw decompiled C code and x86 disassembly. Scene vtable at 0x4D0260
verified by reading raw memory bytes. Level subclass vtables verified
for WarmUp (0x4D04A8), Cascade (0x4D1098), Intermediate (0x4D05A0),
and Dizzy (0x4D0890).
🔗 Related Documents
Raptisoft Live Status Logger
Raptisoft Live Status Logger
Type: bass.dll proxy mod (passive logger — no gameplay changes)
File: bass.dll → writes live_status.txt in the game folder
What It Does
Hamsterball has a hidden in-memory status tracking system that Raptisoft built for crash diagnostics. The game continuously updates three char* fields on the App struct:
| Offset | Field | Purpose |
|---|---|---|
App+0x208 |
Init Status | Startup phase tag (e.g. "App::Initialize(5)", "Graphics::Initialize(10)", "FinishLoad(OK)") |
App+0x20C |
Current Object | Name of whatever object is being processed |
App+0x210 |
Current Operation | Runtime operation ("Background" = message pump, "Update" = game logic frame) |
These fields are never written to disk or console — they exist purely as crash breadcrumbs, read only when the game crashes and feeds them to the BugTracker crash dialog (which posts XML to bugs.raptisoft.com).
This mod taps into those same fields from a background thread and logs them live to live_status.txt.
What It Logs
-
System Info (one-time, when graphics device initializes):
- Product name, version string
- Fullscreen mode, resolution
- Target FPS, refresh rate
- Graphics/sound/music/input/scene device pointers
-
Live Status (on every change, ~100Hz polling):
- Timestamp (ms since mod load)
- Init phase tag
- Current object name
- Current operation
-
Device Changes (every 1s, if pointers change):
- Sound/music/scene manager device transitions
-
FPS (every 1s, if FPS display is enabled in game settings)
Expected Output (on real Windows)
[MOD] Raptisoft Live Status Logger loaded
=======================================================================
HAMSTERBALL LIVE STATUS LOG - Raptisoft Debug Tap
=======================================================================
Log started: 2026-01-15 14:30:22
Process PID: 12345
--- System Info ---
Product: Hamsterball
Version: V3.6.c
Fullscreen: YES
Resolution: 1920x1080
Target FPS: 75
Refresh: 60 Hz
--- Device Pointers ---
Graphics: 0x0A1B2C3D (active)
Sound: 0x0E4F5A6B (active)
Music: 0x0C7D8E9F (active)
Input: 0x0D1E2F3A (active)
Scene/MeshWorld: 0x0F4A5B6C (active)
--- Live Status (updates on change) ---
[tick] STATUS OBJECT OPERATION
-----------------------------------------------------------------------------
[ 50] Startup(2) (null) (null)
[ 100] Startup(3) (null) (null)
[ 150] Startup(8) (null) (null)
[ 200] Startup(9) (null) (null)
[ 250] Startup(Constructor OK) (null) (null)
[ 300] App::Initialize(1) (null) (null)
[ 350] App::Initialize(2) (null) (null)
[ 400] App::Initialize(5) (null) (null)
[ 450] Graphics::Initialize(1) (null) (null)
[ 500] Graphics::Initialize(10) (null) (null)
[ 550] Graphics::Initialize(20) (null) (null)
[ 600] App::Initialize(7) (null) (null)
[ 650] Initialize(5) (null) (null)
[ 700] Initialize(15) (null) (null)
[ 750] Initialize(26) (null) (null)
[ 800] FinishLoad(1) (null) (null)
[ 850] FinishLoad(OK) (null) (null)
[ 900] (null) (null) Background
[ 950] (null) (null) Update
[ 1000] (null) Ball Update
[ 1050] (null) Board(Beginner) Update
Installation
- Copy
bass.dllinto your Hamsterball game folder (replacing the original) - Launch the game
- Check
live_status.txtin the same folder — it updates live while you play
Technical Details
- App global address:
0x005341E0(theg_Appglobal, set inWinMain) - App struct size: ~0x2D00+ bytes
- Polling rate: ~100Hz (10ms sleep between reads)
- Log only on change: Avoids flooding the file with duplicate entries
- Thread-safe: Uses
IsBadReadPtrbefore every memory read - No gameplay changes: Pure passive observer — doesn't hook any game functions
Status Strings Found in Binary
The game contains 100+ status strings across these categories:
- Startup:
Startup(2),Startup(3),Startup(8),Startup(9),Startup(Constructor OK) - App Init:
App::Initialize(1)through(12),App::Initialize(Ok) - Graphics Init:
Graphics::Initialize(1)through(27) - Graphics Defaults:
Graphics::Defaults(1)through(19),Graphics::Defaults(ok) - Full Init:
Initialize(1)through(26)(no 14 or 24 — skipped in binary) - FinishLoad:
FinishLoad(1)through(4),FinishLoad(OK) - Runtime:
Background(message pump idle),Update(game logic frame) - Errors:
"Failed: Direct3DCreate8(D3D_SDK_VERSION)","Could not load sound (1-6)","** No Graphics **","** No Graphics Device **"
Crash Report System (not tapped by this mod)
The game also has a full crash reporting pipeline at 0x0047ABE0:
App_BuildDiagnosticReport(0x0046D230) — collects XML tags: PRODUCT, VERSION, RUNTIME, FULLSCREEN, DXDISPLAY, RESOLUTION, SAFEMODE, OS, DDRAW, DSOUND, CURRENTOBJECT, CURRENTOPERATION, EXTENDED_INFOMWParser_DumpTags(0x004742B0) — formats as XMLBugTracker_ShowDialog(0x0047A480) — shows crash dialog with "Send Report" buttonBugTracker_SubmitReport(0x00479FC0) — HTTP POST tobugs.raptisoft.com/cgi-bin/errorreport.cgi
Build
i686-w64-mingw32-gcc -shared -o bass.dll raptisoft_live_log.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Crash Test
Tested on Wine/Xvfb (35s survival, no crash). Game doesn't fully initialize D3D on llvmpipe, so the log shows only the initial (null) state — but the mod loads cleanly and the background thread runs for the full duration. On real Windows, all status fields will populate normally.
🔗 Related Documents
Raycasting for DLL Mods
Raycasting for DLL Mods — Reusable Ground Detection Pattern
Overview
This reference provides a reusable pattern for using the engine's raycast
function (Mesh_FindClosestCollision @ 0x00465D90) from DLL mods to detect
ground contact, wall proximity, or any spatial query against level geometry.
When to Use Raycasting in a Mod
- Jump mods — detect if the ball is on the ground before allowing a jump
- Wall detection — check if a wall is ahead/behind/beside the ball
- Surface probes — find the height of terrain at a specific XZ coordinate
- Spawn validation — verify a position is safe (not inside geometry)
- Any spatial query that the engine's own physics system would need
The Raycast Function
typedef struct { float x, y, z; } Vec3;
/* Mesh_FindClosestCollision — __thiscall
* ECX = collision_level (Scene+0x8B0)
* Stack: out*, origin(3 floats), direction(3 floats), max_dist(float)
* ret 0x20 (32 bytes = 8 DWORDs on stack)
*
* Address: 0x00465D90
* Returns: same pointer as `out` (always — can be ignored)
*/
typedef Vec3* (__thiscall *MeshRaycast_t)(
void* collision_level, // ECX = Scene+0x8B0
Vec3* out, // output hit point (WRITE target)
Vec3 origin, // ray start point (world space)
Vec3 direction, // ray direction (will be normalized internally)
float max_dist // sphere radius for AABB broad-phase
);
static MeshRaycast_t pfn_raycast = (MeshRaycast_t)0x00465D90;
Critical Typedef Rules
-
__thiscallis mandatory. Without it,collision_levelgoes on the
stack instead of ECX, all params shift left by one slot, and the function
uses garbage for direction — returning wrong results (all directions
return the same hit point ~26 units below origin). -
Vec3 by value = 3 floats on stack. Ghidra shows these as individual
undefined4stack slots. The C compiler handles this correctly when you
use theVec3struct typedef — do NOT pass individual floats. -
ret 0x20(32 bytes). The function pops 32 bytes (8 DWORDs) from the
stack on return. The__thiscalltypedef handles this automatically.
Accessing Collision Geometry
/* From a Ball pointer: */
void* scene = *(void**)((char*)ball + 0x14); /* Ball+0x14 = Scene* */
void* cl = *(void**)((char*)scene + 0x8B0); /* Scene+0x8B0 = CollisionLevel* */
/* From the App global: */
void* app = *(void**)0x005341E0;
void* scene = *(void**)((char*)app + 0x878); /* App+0x878 = Scene* */
void* cl = *(void**)((char*)scene + 0x8B0);
Key Ball Offsets for Raycasting
| Offset | Type | Field | Notes |
|---|---|---|---|
0x014 |
void* |
Scene pointer | → Scene+0x8B0 for CollisionLevel |
0x164 |
float |
Position X | Ball+0x59 (int index) |
0x168 |
float |
Position Y | Ball+0x5A |
0x16C |
float |
Position Z | Ball+0x5B |
0x284 |
float |
Collision radius | Default 26.0, use for max_dist |
0xC4C |
byte |
Fall mode | 1 = dying/respawning, skip raycast |
Ground Detection Pattern (Downward Raycast)
/* Check if ball is on the ground by casting a downward ray.
* Returns 1 if ground is within (radius + 2.0) units below the ball. */
int Ball_IsGrounded(void* ball)
{
char* b = (char*)ball;
void* scene = *(void**)(b + 0x14);
if (!scene) return 0;
void* cl = *(void**)((char*)scene + 0x8B0);
if (!cl) return 0;
Vec3 pos = {
*(float*)(b + 0x164),
*(float*)(b + 0x168),
*(float*)(b + 0x16C)
};
float radius = *(float*)(b + 0x284);
/* Downward ray. For standard levels, (0,-1,0) is correct.
* For tilted-gravity levels, read gravity from CollisionMesh:
* void* cm = *(void**)(b + 0x1A4);
* Vec3 down = { *(float*)(cm+0xC8C), *(float*)(cm+0xC90), *(float*)(cm+0xC94) };
*/
Vec3 down = { 0.0f, -1.0f, 0.0f };
Vec3 out = { 0, 0, 0 };
/* max_dist = radius + 0.5 (matches engine's own ground probes) */
pfn_raycast(cl, &out, pos, down, radius + 0.5f);
/* On hit: out.y ≈ pos.y - radius (floor directly below)
* On miss: out.y ≈ pos.y - 994 (ray endpoint far away)
* Check absolute distance */
float dy = out.y - pos.y;
if (dy < 0.0f) dy = -dy;
return (dy <= radius + 2.0f) ? 1 : 0;
}
No-Hit Behavior (IMPORTANT)
When the ray does NOT intersect any geometry, the function does NOT return
the origin. It returns the ray endpoint at ~994 units along the direction.
- On a downward ray with no floor below:
out.y ≈ pos.y - 994 - Always check
|out - origin|distance after the call - Never assume
out == originmeans "no hit"
max_dist Semantics
max_dist is the sphere radius for AABB broad-phase, not a distance limit:
- It expands the query box around the ray
- Spatial tree returns only triangles within this box
- Use
radius + 0.5f(what the engine uses) - Too large → false hits from off-axis geometry
- Too small → misses nearby geometry
The effective ray length is always ~994 units (direction scaled to 99999,
clamped to 1000 by max_speed, damped to ~994 by friction). max_dist does
NOT control how far the ray travels.
Architecture: Code Cave + Background Thread (Pattern 4)
NEVER call C functions (including raycast) from a hand-assembled code cave.
Even with FNSAVE/FNRSTOR, calling a C function from inside a mid-function hook
corrupts the stack/FPU/SEH state and crashes the game.
The correct pattern for mods that need to call game functions:
┌─────────────────────────────────────┐
│ Code Cave (inside Ball_Update) │ Runs per frame (60fps)
│ - Stores ball pointer in volatile │ Hand-assembled x86, no C calls
│ - Reads volatile flags set by BG │
│ - Applies mod effects │
└──────────┬──────────────────────────┘
│ g_ball_ptr (volatile DWORD)
▼
┌─────────────────────────────────────┐
│ Background Thread (Sleep 10ms) │ Runs ~100x/sec
│ - Reads g_ball_ptr → ball │ C code, can call game functions
│ - Calls Mesh_FindClosestCollision │
│ - Sets g_on_ground (volatile) │
└─────────────────────────────────────┘
Template
/* Shared state */
static volatile DWORD g_ball_ptr = 0; /* set by code cave, read by thread */
static volatile DWORD g_on_ground = 0; /* set by thread, read by code cave */
static volatile DWORD g_bg_active = 1;
static DWORD WINAPI ground_check_thread(LPVOID param)
{
(void)param;
while (g_bg_active) {
DWORD ball_val = g_ball_ptr;
if (ball_val) {
char* ball = (char*)ball_val;
/* Skip if ball is in death/respawn state */
if (*(BYTE*)(ball + 0xC4C) == 0) {
g_on_ground = Ball_IsGrounded(ball);
} else {
g_on_ground = 0;
}
}
Sleep(10);
}
return 0;
}
/* In DllMain DLL_PROCESS_ATTACH: */
CreateThread(NULL, 0, ground_check_thread, NULL, 0, NULL);
/* In DllMain DLL_PROCESS_DETACH: */
g_bg_active = 0;
WaitForSingleObject(g_bg_thread, 2000);
In the code cave, store the ball pointer:
/* MOV [g_ball_ptr], ESI (ESI = ball pointer at Ball_Update hook) */
89 35 <addr_of_g_ball_ptr>
In the code cave, check the ground flag:
/* MOV EAX, [g_on_ground] */
A1 <addr_of_g_on_ground>
/* TEST EAX, EAX */
85 C0
/* JZ .done (not grounded) */
74 <offset>
Performance Notes
Mesh_FindClosestCollisionbuilds a temp SpatialTree per call — not lightweight- Engine calls it 2-3 times per ball per tick; 5-10 extra calls per frame is safe
max_distaffects performance: larger = wider AABB = more triangles tested- Static geometry only — does NOT test against other balls or dynamic objects
- No hit normal returned — only the hit point
Gravity Direction Per Level
For standard levels, (0,-1,0) is the correct downward direction. But some
levels have tilted gravity:
| Level | Gravity Direction | How to detect |
|---|---|---|
| Most levels | (0, -1, 0) |
Standard |
| Up Race (L6) | (-1, 0, 0) |
Tilted |
| odd race | (0, 0, 1) |
Flat |
For robust mods, read gravity from the CollisionMesh:
void* cm = *(void**)((char*)ball + 0x1A4); /* CollisionMesh* (physics body) */
Vec3 down = {
*(float*)((char*)cm + 0xC8C), /* grav_dir_x */
*(float*)((char*)cm + 0xC90), /* grav_dir_y */
*(float*)((char*)cm + 0xC94) /* grav_dir_z */
};
Working Example
See mods/jump_mod/jump_mod.c for a complete working implementation of this
pattern — a jump mod that uses raycast ground detection instead of a cooldown
timer.
Cross-References
references/collision-raycast-system.md— full raycast API, calling convention, max_dist semanticsreferences/ball-ground-detection.md— why built-in ball flags don't work for ground detectionreferences/jump-ground-check.md— jump-specific ground check approachesreferences/bass-proxy-debugging.md— BASS proxy DLL pattern, code cave pitfallsmods/jump_mod/README.md— working mod using this pattern
🔗 Related Documents
RE - TODO List
Hamsterball RE - TODO List
Milestone 1: Executable Environment and Binary/Resource Inventory ✅
- [x] Download and preserve original binaries (installer + installed folder)
- [x] Compute SHA256 hashes of all 450 files
- [x] Extract installed game folder (1,404,928 byte EXE + 450 asset files)
- [x] Document file formats (MESHWORLD, MESH, MO3, OGG, XML, CFG)
- [x] Document DLL dependencies (d3d8, dinput8, dsound, bass, etc.)
- [x] Install Wine 9.0, test game launch (partial success - no display)
Milestone 2: Subsystem Map and First Labeled Ghidra Database ✅
- [x] Run Ghidra headless analysis (3,781 functions analyzed)
- [x] Use r2 for initial function mapping (1,869 functions found)
- [x] Identify entry point (0x004BB4C8)
- [x] Identify Graphics::Initialize (0x00455380)
- [x] Identify window class "AthenaWindow"
- [x] Map import table (9 DLLs, 177 imports)
- [x] Map key string addresses (App::Initialize, etc.)
- [x] Connect Ghidra MCP for interactive decompilation (headless server on port 8089)
- [x] Label top 50 functions in Ghidra (57 labeled, now 975+)
- [x] Map all subsystem addresses (graphics, audio, input, physics, UI, save, DRM)
- [x] 100% DOCUMENTED: All 3,781 functions identified and renamed (0 FUN_ remaining)*
Milestone 3: Documented Runtime Dependencies and Launch Procedure 🔄
- [x] Document DLL dependencies (9 DLLs)
- [x] Document registry keys (ADVAPI32 functions identified)
- [x] Document file paths (DATA\HS.CFG, textures, levels, meshes)
- [ ] Test full game launch with Xvfb + Wine
- [ ] Install DirectX 8 runtime via winetricks
- [ ] Document all registry writes during launch
- [ ] Test multiplayer / tournament save
Milestone 4: Reconstructed Core Loop and Resource Loading ✅ (mostly)
- [x] Decompile WinMain function
- [x] Decompile App::Initialize steps 1-26
- [x] Map game loop (PeekMessageA → Update → Render)
- [x] Reverse MESHWORLD file format parser (MeshWorld_Parse at 0x470930)
- [x] Reverse MESH file format parser (same parser, different loading)
- [x] Reverse texture loading (Graphics_LoadTexture, D3DX embedded)
- [x] Reverse audio initialization (BASS_Init, BASS_MusicLoad)
- [x] Reverse input initialization (DirectInput8Create, Input_Init)
Milestone 5: Reconstructed Input/Render/Physics/Menu Pipeline ✅
- [x] Reverse D3D8 device creation and render pipeline
- [x] Reverse DirectInput keyboard/gamepad input (Input_Init, KeyboardDevice)
- [x] Reverse ball physics (Ball_Update 18KB, Ball_ApplyForce, Ball_CheckCollisionPlanes)
- [x] Reverse camera system (CAMERALOOKAT)
- [x] Reverse level object system (N:/E: prefixes)
- [x] Reverse AthenaList container class (0x53210)
- [x] Document ball vtable and physics constants
Milestone 6: First Playable Open-Source Build 🔄 — Phase 2 Deep Docs Done
- [x] Create C project scaffold with D3D8/DInput8/DSound8 (Windows APIs)
- [x] Implement MESH file loader
- [x] Implement MESHWORLD file loader
- [x] Build and run on Wine (D3D8 rendering works, 30K+ FPS counter)
- [x] WinMain (0x4278E0) mirrored with full D3D8 pipeline
- [x] Arena-WarmUp level loads: 17 objects, ball at (184.9, 26.4, 183.4)
- [ ] Handle DirectInput window focus in Xvfb (ball movement)
- [ ] Implement collision plane system (Level_LoadCollision RE in progress)
- [ ] Render full MESHWORLD geometry (not just ball/objects)
Phase 2 Deep Documentation (Complete)
- PARTICLE_SYSTEM.md: Ball_CreateTrailParticles (9-particle ring, ArenaScoreParticle allocator)
- CAMERA_SYSTEM.md: 5-mode camera (follow/path/shake/snap/orbit), Scene_SetCamera offsets
- SAVE_CONFIG_REGISTRY_SYSTEM.md: 30+ registry fields, BestTime/Medals binary blobs, App struct
- AUDIO_SYSTEM_SFX.md: BASS music + DirectSound 3D SFX, 55 sound effects with channel counts
- LEVEL_OBJECT_FACTORY.md: 25+ Create* functions, struct sizes, scene offsets, full factory map
- ASSET_MANIFEST.md: Complete resource loading manifest (5 fonts, 40+ textures, 14 meshes, 55 sounds)
- GAME_STATE_RACE_LIFECYCLE.md: App state machine, start/end race, tournament progression, timer
- D3D8_RENDERING_PIPELINE.md: 8-pass render system, z-buffer interleaving, alpha blending, lighting
- SCENE_STRUCT.md: 50+ Scene struct offsets, creation flow, camera config, object lists
- INPUT_SYSTEM: Ball_GetInputForce (3-mode: keyboard/mouse/joystick), Scene_HandleInput dispatch
- Ball physics: Ball_AdvancePositionOrCollision 6-phase pipeline (damping, collision, gravity, trail)
- Race completion: RaceGoalReached ctor, medal thresholds, best time tracking
- UI_MENU_SYSTEM_DEEP.md: 101 menu functions, MainMenu 7 items, DifficultyMenu 4 items, UIListItem 0x444B struct
- COLLISION_SYSTEM_DEEP.md: Octree traversal, AABB, CollisionMesh 0xCB0, .COL format, 3-tier dispatch
- FONT_TEXT_SYSTEM.md: font.description binary format, FontList struct, SDF builder, 5 fonts
- DIRECTINPUT_SYSTEM.md: InputDevice 0x91C, 3 input modes, DIK scan codes, Scene_HandleInput
- GAME_LOOP_WINDOW_MANAGEMENT.md: App_Run fixed-timestep, vtable dispatch, frame timing, WinMain
- Decompilations: camera/, particles/, save/, input/, audio/, scene/, physics/, level/
Milestone 7: Behavior-Polish and Bug Reduction
- [ ] Match physics feel of original
- [ ] Match camera behavior
- [ ] Match audio triggers
- [ ] Match menu flow
- [ ] Match scoring system
Milestone 8: Selective Machine-Code Matching
- [ ] Identify critical functions for codegen matching
- [ ] Set up MSVC 6/7 build environment
- [ ] Match specific function signatures
- [ ] Verify with binary diff
Milestone 9: Scene System Documentation ✅
- [x] Decompile Scene_dtor (0x419770) - master scene destructor
- [x] Decompile Scene_Update (0x419C00) - main tick function
- [x] Decompile Scene_Render (0x41A2E0) - 1P/2P/split render
- [x] Decompile Scene_SetCamera (0x419FA0) - camera positioning (5 modes)
- [x] Decompile Scene_RenderWithCamera (0x40DFA0) - two-pass render
- [x] Decompile Scene_UpdateBallsAndState (0x41B540) - per-ball physics + respawn
- [x] Decompile Scene_HandleRaceEnd (0x41B130) - race finish conditions
- [x] Decompile Scene_ProcessRaceEnd (0x41A540) - race countdown
- [x] Decompile Gear_AdvanceAlongPath (0x418930) - 8-sample gradient descent path follower
- [x] Document Ball_Update (0x405E00) - 23-step physics pipeline
- [x] Document Ball vtable (0x4CF3A0, 9 entries) and App_Run loop (0x46BD80)
- [x] Document Scene vtable (0x4D0260, 36 entries, all named)
- [x] Document SceneObject class (vtable 0x4D934C, 0xD4 bytes)
- [x] Document 8 level setup functions (leveldark through levelup)
- [x] Document 3 reflection render passes
- [x] Document scene object lists (6 lists at known offsets)
- [x] Document all 15 ArenaBoard arena init functions
- [x] Document Board (Tournament) constructors (Toob/Rodenthood level8)
- [x] Deep doc: SCENE_SYSTEM_DECOMP.md with full tick order, render pipeline, camera modes
- [x] Document remaining level setup functions (if they exist as Scene_Setup)
- [x] Document Level_UpdateAndRender (0x40B600) - 6-phase level render pipeline
- [x] Document Level_RenderObjects (0x40B570) - transparent pass renderer
- [x] Document Scene_CheckPath (0x57EC0) - 359-cell ring pathfinder
- [x] Document Scene_SpawnBallsAndObjects (0x41C5B0) - level startup spawner
- [x] Document Scene_RenderAllObjects (0x45E0E0) - 3-pass object render
- [x] Document Scene_RenderFrame (0x60DA0) - vertex buffer construction + font render
Milestone 10: Rendering Pipeline Documentation ✅
- [x] Document SceneObject_RenderFull (0x470150) - full render with alpha path
- [x] Document SceneObject_RenderSingleObj (0x470440) - single object render
- [x] Document SceneObject_BuildStrips (0x472770) - triangle strip builder
- [x] Document SceneObject_CheckCollision (0x45dfd0) - collision thunk
- [x] Document SceneObject_ComputeCollisionSphere (0x46fbb0) - bounding sphere
- [x] Document MeshWorld_ctor (0x46f3d0) - mesh world construction
- [x] Document Mesh_InitTexture (0x49338e) - D3D texture init
- [x] Document Mesh_DrawWithTransform (0x493671) - draw with temp transform
- [x] Document Mesh_ClearColorVertices (0x49373d) - zero matching vertices
- [x] Document Graphics_DrawIndexedPrimitive (0x47dfb9) - D3D draw wrapper
- [x] Document Scene_BeginFrameThenRender (0x46f3b0) - frame begin wrapper
- [x] Document D3D vtable dispatch map (9 offsets: 0x28-0x200)
- [x] Document Graphics_ApplyMaterialAndDraw (0x455110) - material system
- [x] Document Graphics_RenderScene (0x454BC0) - full 3D render pipeline
- [x] Document D3D texture system (Ctor/Dtor/Init/Release 13 functions)
- [x] Document material structure layout (0x50 bytes, 8 fields)
- [x] Document graphics subsystem functions (25+ functions cataloged)
Milestone 11: AthenaString System Documentation ✅
- [x] Document AthenaString_AssignCStr (0x473500) - C string assign (75 xrefs)
- [x] Document AthenaString_dtor (0x4736b0) - destructor (85 xrefs)
- [x] Document AthenaString_AssignCRLF (0x473a50) - CRLF assign (21 xrefs)
- [x] Document AthenaString_SprintfToBuffer (0x4bae43) - sprintf to buffer (71 xrefs)
- [x] Document AthenaString_Sprintf (0x4bbdfd) - already known
- [x] Document AthenaString_Format (0x466c70) - already known
- [x] Document AthenaString_Assign (0x4737f0) - already known
- [x] Document AthenaString_Init/CopyCtor/WriteTag - already known
New Documentation Created
BALL_PHYSICS_DECOMP.md- Full ball physics system decompilationMESHWORLD_OBJECT_TYPES.md- N:/E: object type reference- Updated
FUNCTION_MAP.mdwith Input, Texture, Ball systems - Updated
KEY_DECOMPILATIONS.md
🔗 Related Documents
RE — Key Findings Summary
Hamsterball RE — Key Findings Summary
Session Date: April 12, 2026
1. Game Loop (App::Run)
Address: 0x46BD80
Function Signature:
void App::Run(App* this); // ECX = App* (0x4FD680)
Key Offsets:
| Offset | Field | Description |
|---|---|---|
| +0x158 | minimized | 1 if minimized |
| +0x159 | running | 1 to quit |
| +0x15A | active | 1 if game active |
| +0x164 | last_tick | GetTickCount() |
| +0x168 | frame_time | 1000 / target_fps |
| +0x170 | target_fps | e.g., 60 |
| +0x174 | graphics | Graphics* |
| +0x18C | frame_counter | frames since last FPS reset |
| +0x194 | frame_count | FPS display counter |
| +0x198 | fps_buffer | FPS string display |
| +0x1AC | show_fps | 1 to show FPS |
Loop Logic:
- PeekMessage loop (message pump)
- If elapsed < frame_time - 5ms: skip (count skipped frames)
- After 10 skips: force update
- Otherwise: Update() → Draw() → Present()
- Sleep/yield between frames
2. Ball Physics
Ball Vtable (0x4CF3A0)
| Index | Offset | Address | Function | Description |
|---|---|---|---|---|
| 0 | +0x00 | 0x4027F0 | Ball_dtor | Cleanup and free |
| 1 | +0x04 | 0x405100 | Ball_Update | Per-frame physics init |
| 2 | +0x08 | 0x402DE0 | Ball_CollisionCheck | Check collisions |
| 3 | +0x0C | 0x402A70 | OnCollision | Handle collision event |
| 4 | +0x10 | 0x408390 | Ball_Render | Render the ball |
| 5 | +0x14 | 0x401590 | vtable[5] | Calls vtable[6] with extra param |
| 6 | +0x18 | 0x402650 | Ball_ApplyForce | Apply force/velocity |
| 7 | +0x1C | 0x402C10 | ??? | Physics plane check |
| 8 | +0x20 | 0x409480 | ??? | Unknown |
Ball Object Structure
| Offset | Type | Field | Description |
|---|---|---|---|
| +0x164 | float | x | Position X |
| +0x168 | float | y | Position Y |
| +0x16C | float | z | Position Z |
| +0x170 | float | vx | Velocity X |
| +0x174 | float | vy | Velocity Y |
| +0x178 | float | vz | Velocity Z |
| +0x17C | float | ang_vel_z | Angular velocity Z |
| +0x180 | float | ang_vel_y | Angular velocity Y |
| +0x184 | float | ang_vel_x | Angular velocity X |
| +0x198 | float | facing_angle | Movement direction |
| +0x19C | char | facing_valid | Angle computed |
| +0x278 | float | ??? | 0.5 (set by Update dispatcher) |
| +0x27C | float | friction | 0.2 (set by Update dispatcher) |
| +0x280 | char | ??? | Collision flag |
| +0x284 | float | radius | 35.0 (ball radius) |
| +0x2CC | char | no_input | No player input |
| +0x2E8 | char | flag1 | Reset to 0 each frame |
| +0x2E9 | char | impact_shatter | ⚠ NOT "reset to 0 each frame"! Sticky flag, only cleared by Ball_ctor2. See docs/agent-knowledge/ball-ground-detection.md |
| +0x2EC | uint32 | ??? | Reset to 0 each frame |
| +0x2F0 | uint32 | force_counter | Forces applied this frame |
| +0x2F8 | char | update_in_progress | Set to 1 during update |
| +0x2F9 | char | frozen | Ball frozen (on surface) |
| +0x2FC | uint32 | freeze_timer | Countdown when frozen (150) |
| +0x310 | char | ??? | Set to 1 each frame |
| +0x314 | float | ang_vel_y | Set to 0 on snap |
| +0x318 | float | ang_vel_x | Set to 0 on snap |
| +0x31C | char | ??? | Set to 0 on snap |
| +0x31D | char | ??? | Render flag |
| +0x324 | char | in_tube | Ball is in tube/pipe |
| +0x748 | int | gravity_plane | 0=XY, 1=Y-tilted, 2=XZ |
| +0xC4C | char | is_shrunk | odd race shrunk state (E:SHRINK/E:GROW) |
| +0xC5C | int | on_ice | Ice surface |
| +0xC74 | int | collision_count | Reset to 0 each frame |
| +0xC78 | float | ??? | 0.0 (set by Update dispatcher) |
| +0xC7C | float | ??? | 50.0 (set by Update dispatcher) |
| +0xC6C | float | ??? | 600.0 (set by Update dispatcher) |
| +0xC70 | float | ??? | 1200.0 (set by Update dispatcher) |
| +0xC80 | char | ??? | Has special collision |
| +0xC88-+0xC94 | float[4] | special_col | Special collision data |
Ball_ApplyForce (0x402650)
void Ball_ApplyForce(ball, force_x, force_y, force_z, magnitude);
Logic:
- If frozen or no input: ignore
- Apply force multiplier based on:
- force_counter (first frame = 1.0, then *0.25)
- in_tube (*0.0 — no force in tube!)
- on_ice (*0.2 + ice friction 6.0)
- is_dizzy (*0.75)
- Accumulate into velocity at +0x170/0x174/0x178
- Compute facing angle based on gravity plane
Ball_Update (0x405190)
The main per-frame physics update:
- If in_tube: skip (return immediately)
- Reset per-frame state
- Find closest collision surface based on gravity_plane:
- Plane 0: XY (standard), check surfaces with [Z] tag
- Plane 1: Y-tilted, check surfaces with [X] tag
- Plane 2: XZ, check surfaces with [Z] tag
- If surface found: snap position to surface, zero velocity, set frozen=1
- If no surface: ball falls freely
Global Constants
| Address | Float | Value | Usage |
|---|---|---|---|
| 0x4CF368 | 0.0 | epsilon | Float comparison epsilon |
| 0x4CF36C | 0.75 | dizzy_mult | Force multiplier when dizzy |
| 0x4CF374 | 0.2 | ice_mult | Force multiplier on ice |
| 0x4CF378 | 0.0 | tube_mult | Force multiplier in tube |
| 0x4CF380 | 0.25 | force_mult | Force multiplier after 1st frame |
| 0x4CF39C | 0.037 | radius_mult | Collision radius multiplier |
| 0x4CF3E8 | 6.0 | ice_friction | Ice friction factor |
| 0x4CF484 | 40.0 | col_mesh_dist | Collision mesh distance threshold |
| 0x4CF48C | 2.0 | y_threshold | Y offset threshold (radius + epsilon) |
3. Binary MESHWORLD Format
Format:
[uint32: object_count]
[uint32: str_len][type_string][object_data...]
[uint32: str_len][type_string][object_data...]
...
Object Data Sizes:
- Simple objects (START, SAFESPOT): 28 bytes after type string
- 3 floats: position (x, y, z)
- 4 uint32: rotation/flags
- Complex objects (FLAG, PLATFORM): 76+ bytes
- Transform matrix, colors, size param
- Texture reference string
- Face index data
Object Types Found:
- START1-1, START2-1, START2-2 (spawn points)
- FLAG02, FLAG04, FLAG06, FLAG07 (checkpoints)
- SAFESPOT (safe zones)
- PLATFORM, N:SINKPLATFORM (platforms)
- N:BUMPER1-4 (bumpers)
- E:NODIZZY (dizzy zones)
- E:LIMIT (boundaries)
- CAMERALOOKAT (camera points)
- BADBALL (enemies)
- BCMESH (ball mesh reference)
- S:Walls(NOSHADOW) (walls)
4. Binary MESH Format (3D Models)
Header:
[uint32: version = 1]
[uint32: name_len]
[name_len bytes: model name]
[material data: ambient, diffuse, specular, etc.]
[uint32: texture_name_len]
[texture_name_len bytes: texture filename]
[uint32: vertex_count]
[vertex data: vertex_count * 32 bytes]
[face data: ...]
Vertex Format (32 bytes):
[float: x][float: y][float: z] // position
[float: nx][float: ny][float: nz] // normal
[float: u][float: v] // texture coordinates
5. Key Functions Decompiled
| Address | Function | Lines | Description |
|---|---|---|---|
| 0x46BD80 | app::run | 91 | Game loop |
| 0x429530 | App::Initialize | ~200 | App init |
| 0x405190 | Ball_Update | 477 | Main physics |
| 0x405100 | Ball_Update_disp | 27 | Physics init dispatcher |
| 0x402650 | Ball_ApplyForce | 47 | Force/velocity |
| 0x402DE0 | Ball_CollisionCheck | 99 | Collision check |
| 0x402A70 | OnCollision | 64 | Collision event |
| 0x402C10 | ??? | 68 | Physics plane check |
| 0x402860 | Ball_Render | 41 | Ball render stub |
| 0x408390 | Ball_Render | 166 | Actual ball render |
| 0x40AFE0 | Ball_ctor | 30 | Ball constructor |
| 0x455380 | Graphics_Initialize | 272 | D3D init |
| 0x470930 | MeshWorld_Parse | 787 | ASE text parser |
| 0x4629E0 | Binary_mesh_loader | 289 | Binary loader |
6. Virtual Tables
App vtable (0x4D8FB0)
- +0x00: Destructor
- +0x0C: LoadMesh
- +0x30: LoadMeshWorld
- +0x38: Binary mesh loader
Ball vtable (0x4CF3A0)
- +0x00: dtor (0x4027F0)
- +0x04: Update dispatcher (0x405100)
- +0x08: CollisionCheck (0x402DE0)
- +0x0C: OnCollision (0x402A70)
- +0x10: Render (0x408390)
- +0x14: vtable[5] (0x401590)
- +0x18: ApplyForce (0x402650)
- +0x1C: vtable[7] (0x402C10)
- +0x20: vtable[8] (0x409480)
7. Files Generated
analysis/ghidra/decomp_*.c— Full decompilations (30+ files)docs/GAME_LOOP_DECOMP.md— app::run detailsdocs/BALL_UPDATE_DECOMP.md— Ball physics detailsdocs/MESHWORLD_FORMAT.md— Format specdocs/KEY_FINDINGS.md— This summaryreimpl/src/level/meshworld_parser.c— Working parserreimpl/build/level_viewer— SDL2+OpenGL viewer
8. Statistics
- Total decompiled C code: ~2500 lines
- Functions decompiled: 15+
- Object types found: 20+
- Level files parsed: 86
Last updated: April 13, 2026
Project: Hamsterball RE (Raptisoft, 2000s)
Permission: Original developer granted permission for RE
9. Scene System (April 13, 2026)
Scene Architecture
The game uses a virtual-dispatch scene system. Each race level creates a scene object with a vtable that provides:
- dtor (+0x00): Master cleanup (Scene_dtor at 0x419770)
- vmethods +0x4C-0x58: Update render passes (PreRender, RenderLevel, RenderDynamic, PostRender)
- vmethod +0x60/0x64/0x68: Render3D, RenderObjects, RenderOverlay
- vmethod +0x6C/0x70: RenderMenu, RenderHUD
- vmethod +0x7C/0x80: Per-dynamic-object update, per-scene init
Scene_Update (0x419C00) is the main tick:
- Increment frame counter (+0xD88)
- Demo timer check (50-frame countdown, shows buy dialog)
- Unpause check (Input_CheckKeyCombo mode 2 for ESC)
- Ball position sync from +0xA75 list
- Camera tracking (ball at App+0x5DC)
- Screen offset animation (+0xA6E, -10/frame toward 0 or -800)
- Static object update + removal of dead objects (+0x22E list)
- Render pass vmethods (+0x4C, +0x50, +0x54, +0x58)
- Dynamic object iteration (+0xD8B list)
Scene_Render (0x41A2E0) dispatches by player count:
- 1P: Single viewport, all render passes
- 2P: Set camera from player list at +0x3A38
- 3-4P: Split-screen per viewport, per-camera
SceneObject Class (0x4D934C)
Common renderable game object (0xD4 bytes):
- 3 matrix transforms: base scale (+0x94), rotation (+0xA8), world (+0xBC)
- Visible flag (+0x88), zOrder/object ID (+0x8C)
- Bounding radius (+0xCC), type (+0xD0)
- Scene registration via Scene_RegisterObject stores at scene+0x710+id*4
Level Setup Pattern
All level setups follow: Level_ctor → Level_Clone → Level_InitScene → vmethod +0x80, then level-specific object creation (flags, signs, bumpers, vacuum tubes, etc).
| Level (internal) | Address | Race | Special Objects |
|---|---|---|---|
| Level1 | 0x41CA40 | Warm-up Race | — |
| levelcascade | 0x4110D0 | Beginner Race | 8 bumpers |
| Level2 | (base) | Intermediate Race | — |
| Level3 | 0x416270 | Dizzy Race | 2-player support |
| Level4 | 0x40E190 | Tower Race | — |
| Level6 | 0x40830 | odd race | PILLAR, MAGNIFYER, CLOUDSCAPE, fog |
| LevelDark | 0x40F360 | Neon Race | — |
| Level5 | 0x40EA90 | Expert Race | LAUNCH01/02/03, CHROMESHADOW |
| Level8 | 0x411F60 | Toob Race | 4 bumpers, TarBubble |
| Level7 | (base) | Wobbly Race | — |
| LevelGlass | (base) | Glass Race | — |
| Level9 | (base) | Sky Race | — |
| Level10 | (base) | Master Race | — |
| LevelUp | 0x411540 | Up Race | VAC-IN/VAC-OUT vacuum tubes |
| LevelImpossible | (base) | Impossible Race | — |
Scene Vtable (0x4D0260, 36 entries)
Key identified slots:
- [0] Scene_DeletingDtor - destructor + conditional free
- [1] Scene_Update - main 9-step tick
- [2] Scene_Render - 1P/2P/split render dispatch
- [3] Scene_HandleInput - menu item iteration, input check, current item at +0x864
- [4] Scene_ActivateCurrentItem - call vmethod+0x10 on current item
- [6] Scene_SelectCurrentItem - call vmethod+0x0C on current item
- [11/12] Scene_ClearCurrentItem - set current item pointer to NULL
- [14] Scene_DestroyScene - cleanup + save via SaveAndCleanup
- [15] Scene_NotifyObjects - iterate list, call FUN_4699D0 on each
- [16] Scene_SetDestroyed - set +0x2C=1 flag
- [17] Scene_SaveAndCleanup - delegates to FUN_469AC0
- [18] Level_InitScene
- [19] Scene_HandleRaceEnd - timer decrement, lap tracking, Game Over, RaceResultPopup ctor
- [20] Scene_UpdateBallsAndState - ball list iteration, SetCamera, Ball_Update, destroy finished
- [22] Scene_ProcessRaceEnd - race countdown timer, scene transition on expire
- [23] Scene_HandleBallFinish - ball finish state machine (start→150f countdown→finish→popup→done)
- [24-26] Level render methods
- [27] Scene_RenderScoreHUD - tournament title, countdown bar, "Score", Player 2, timer
- [28] Scene_RenderTimerHUD - race timer, split screen divider, overlay popups
- [9,10,13,21,30,34] NoOp_return (0x44B840) - default stubs overridden by subclasses
SceneObject Vtable (0x4D934C, 10 entries)
- [0] SceneObject_dtor (0x46B650)
- [1] SceneObject_SetPosition (0x46B490) - sets +0x08,x,y,z + calls vmethod+0x0C
- [2] SceneObject_SetScale (0x46B4B0) - sets +0x14 scale + calls vmethod+0x0C
- [3] SceneObject_Render (0x46B670) - build world matrix, D3D SetTransform+SetMaterial
- [4] SceneObject_SetVisible (0x46B4D0) - toggle +0x88
- [7] SceneObject_DeletingDtor (0x46B9F0)
- SceneObject_BaseDtor (0x46B860) iterates child list, calls dtor(1) on each child
Rumble/Arena Board System
15 arena init functions (ArenaBoard_*_Init) follow this pattern:
- Level_ctor(arena_level_path) → store at scene+0x22B
- Level_Clone(source) → store at scene+0x22C
- CameraLookAt(scene)
- Virtual dispatch vmethod+0x80 (post-setup)
Special arena behaviors:
- Warm-up (0x413C20): —
- Beginner (0x413CE0): 4 bumpers via N:BUMPER%d (CASCADERACE = Beginner Race)
- Toob (0x414F00): 5 bumpers via N:BUMPER%d
- Dizzy (0x414240): Extra Level3-Swirl loaded
- Sky (0x4158C0): PILLAR name scanning via __strnicmp
- Neon (0x416F40): Scale matrix setup for dynamic objects, SceneObject decoration at +0x11F9
Tournament Board Constructors
Board subclasses (0x419030 base) hold sub-level arrays:
- Toob Board (0x41F4B0): "Rodenthood" tournament with 5 sub-levels:
Level8-Spinny, Level8-Saw, Level8-Fallout, Level8-Blockdawg1, Level8-Blockdawg2
Scene Object Lists (Scene offsets)
| Offset | Description |
|---|---|
| +0x22E | Static scene objects (persistent) |
| +0x335 | Dynamic objects (moving platforms etc) |
| +0x43B | Sub-objects (linked to dynamic) |
| +0xA75 | Ball list (per-ball position updates) |
| +0xC81 | Additional object list |
| +0xD8B | Render update list |
Camera System Detail
Scene_SetCamera (0x419FA0) positions camera:
- Start from ball position +0x758
- Add scene offset (+0x434C)
- If boundary check enabled (+0x3F1C): compute distance from center, clamp with distance falloff
- If countdown timer active (+0x3F2C): snap camera to ball for initial frames
- Noise randomizer at ball+0x744 (jitters position)
- Set view/projection matrices
Difficulty System
Difficulty_GetTimeModifier (0x428ED0) returns multiplier based on +0x23C:
- Mode 0: Easy (time * _DAT_4CF3F0)
- Mode 1: Normal (time * 1.0)
- Mode 2: Hard (time * 2.0)
- Default: 0.0 (no time?)
10. Documentation Progress
- Total functions: 3,988
- Documented: 1,816 (45.5%)
- Session progress: 44.1% → 45.5% (+78 functions named across sessions)
- Key docs updated: FUNCTION_MAP.md, KEY_FINDINGS.md, STRUCTS_AND_TYPES.md
11. PRNG System (RNG_Rand, 0x45DD60)
- 55-entry circular buffer (additive generator, like Mitchell & Moore)
- Two pointers wrap at index 55 (0x37)
- Returns
(buf[read_ptr] + buf[write_ptr]) & 0x3FFFFFFF >> 6) % range - Optional signed mode: if param_2=1 and RNG_Rand(2)==0, negate result
- 193 cross-references — used everywhere for randomization
12. Graphics Transform Pipeline
- Direct3D 8 SetTransform wrapper chain:
- Gfx_SetPosition (69 xrefs) → D3D world matrix translate
- Gfx_RotateY (15 xrefs) → rotation around Y
- Gfx_ScaleX/Y/Z (40/35/26 xrefs) → scale individual axes
- Matrix44_Zero → clear, then set diagonals to 1.0
- Gfx_SetAlphaBlendState → D3DRS_SRCBLEND/DESTBLEND
- Gfx_SetCullMode → D3DRS_CULLMODE (none/CW/CCW)
13. UI List System (vtable 0x4D6A70)
- Base class for all menu screens in the game
- SimpleMenu_ctor (0x448F20) sets up "Simple Menu" with item list
- Items are 0x444-byte structs with: display text, subtext, color, SceneObject icon, height
- UIList_AddItem (86 xrefs): creates item, copies text, links SceneObject
- UIList_AddSpacer (29 xrefs): adds empty row with height
- Rendering: UIList_Render draws items with gradient bar selection, icons, scroll arrows
- Input: UIList_HandleKeyNav for up/down navigation, UIList_ScrollUpdate for mouse wheel
- UIList_ActivateCurrentItem: "Back" → sound 650, "Continue" → sound 50, else vtable dispatch
- UIList_Layout: computes total widths, positions SceneObjects for 2D rendering
14. Rumble Board System (vtable PTR 0x4D1358)
- ArenaBoard extends Board (which extends Scene)
- Base score: 6000 per round
- 25 rounds per game (offset +0x47D0 = 0x19 = 25)
- Timer: ToggleTimer_Init / TickTimer / CleanupTimer manage round time
- ArenaBoard_Render draws timer bar, round number ".%d", and "TIE BREAKER!" text
- ArenaBoard_Update checks round end: finds max score, handles ties
- Tie detection: counts how many players share max score; if ≥2, sets tie breaker flag
- On game over: spawns ArenaScoreParticle object (FUN_4CB10), plays "Game Over" music
- ArenaScoreParticle_ctor uses difficulty index [0,1,2] → scale [0.02, 0.03, 0.04]
- ArenaBoard vtable at PTR_FUN_004d1358
15. Scene Rendering Pipeline (0x45E0E0)
- Scene_RenderAllObjects is the main 3D render function
- Three-phase draw: opaque objects → alpha-blended objects → shadow objects
- Object flags at offsets: +0x85F (shadow), +0x860 (alpha), +0x862 (deferred), +0x863 (skip)
- For alpha objects: temporarily shifts projection matrix for shadow rendering
- Each object has material index (+0x83C) and bone array for skeleton rendering
- Scene_RenderBallShadow: renders ball with depth bias for shadow pass
16. SceneObject Rendering Pipeline (Session 14)
SceneObject_RenderFull (0x470150, 40 xrefs)
- Full SceneObject render: calls Ball_Render, then iterates strips with material application
- Two rendering paths based on alpha flag at +0x459:
- Alpha-blended (flag==1): per-strip material application, iterates strips via AthenaList
- Opaque (flag==0): direct strip dispatch via Graphics_ApplyMaterialAndDraw
- Uses Graphics_ApplyMaterialAndDraw for mesh rendering
SceneObject_RenderSingleObj (0x470440, 39 xrefs)
- Renders a single SceneObject: applies world transform, sets material, calls DrawIndexedPrimitive or strips
- Similar to SceneObject_RenderFull but for a specific object parameter
- Handles both alpha and non-alpha materials
SceneObject_BuildStrips (0x472770, 39 xrefs)
- Builds triangle strips for SceneObject rendering
- Iterates mesh nodes, creates strip data entries into the strip list (AthenaList)
- Interleaves vertex positions for efficient GPU upload
- Sets alpha flag on this+0xe
- Result stored at this+8 offset 0x454
SceneObject_CheckCollision (0x45dfd0, 38 xrefs)
- Collision test thunk: computes bounding sphere from AABB (field 0x45c-0x470 extents * 0.5)
- Calculates radius via distance formula, then calls Ball_CheckCollisionPlanes
- Used per-SceneObject in collision detection
SceneObject_ComputeCollisionSphere (0x46fbb0, 38 xrefs)
- Inner function: computes bounding sphere from AABB
- Computes extents: (min+max) * 0.5 for each axis
- Calculates radius: sqrt(dxdx + dydy + dz*dz)
- Caches result at +0x480 to avoid recomputation
Scene_BeginFrameThenRender (0x46f3b0, 39 xrefs)
- Begins graphics frame (Graphics_BeginFrame), then invokes render callback
- Simple wrapper around vtable dispatch at +0x10
17. Mesh/Texture System
MeshWorld_ctor (0x46f3d0, 39 xrefs)
- MeshWorld constructor: takes scene ptr, strip count, and filename
- Allocates vertex/index/strip buffers
- If cached file exists with valid magic (0xBEEF), loads binary strip data
- Otherwise allocates empty buffers for scene to fill
Mesh_InitTexture (0x49338e, 32 xrefs)
- Initializes D3D texture object from DDSURFACEDESC
- Sets vtable at 0x4DC044, copies surface desc fields (pitch/stride/width/height)
- If format is D3DFMT_A8R8G8B8 or D3DFMT_X8R8G8B8 (0x29/0x28), loads palette data
- If has palette ptr, copies 256 RGBA entries; else fills with 1.0
Mesh_DrawWithTransform (0x493671, 31 xrefs)
- Draws mesh with temporary transform override
- Saves current transform state, applies new transform, restores
Mesh_ClearColorVertices (0x49373d, 30 xrefs)
- Zeroes out vertices matching the clear color (transparency hack)
- Iterates vertex buffer, clears vertices that match clear color RGBA
D3DDevice_SetFPUControl (0x49336b, 29 xrefs)
- Sets FPU control word from device state
- Maintains FPU precision for D3D rendering
18. AthenaString System
AthenaString_AssignCStr (0x473500, 75 xrefs)
- AthenaString assign from C string
- Frees old buffer at +4, allocates new buffer sized to strlen(param_1)+1
- Copies string, sets null-flag at +0x18 if param_1 is NULL
AthenaString_dtor (0x4736b0, 85 xrefs)
- AthenaString destructor
- Sets vtable to base dtor vtable (0x4D290C), frees internal buffer at +4
- Zeros length at +0x14 and capacity at +0x8
AthenaString_AssignCRLF (0x473a50, 21 xrefs)
- Assigns CRLF ("\r\n") to AthenaString
AthenaString_SprintfToBuffer (0x4bae43, 71 xrefs)
- sprintf into char buffer via fake FILE struct
- Uses same FILE-trick as AthenaString_Sprintf but writes to caller-provided buffer
19. Registry Functions
RegKey_WriteBool (0x473050, 30 xrefs)
- Write boolean to registry via RegSetValueExA (REG_BINARY)
RegKey_ReadBool (0x473130, 28 xrefs)
- Read boolean from registry via RegQueryValueExA
RegKey_ReadString (0x473170, 23 xrefs)
- Read string from registry with fallback attempts
20. CRT/Compiler Intrinsics (not game code, but documented)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4ba754 | __ftol2 | 358 | CRT float-to-int64 conversion, compiler intrinsic |
| 0x4bc7c8 | __errno | 40 | Returns thread-local errno pointer |
| 0x4bc7d1 | __doserrno | 23 | Returns thread-local DOS errno pointer |
| 0x4bcda8 | __security_init_cookie | 34 | CRT security cookie initialization |
| 0x4bac20 | strstr | 22 | String search with SIMD optimization |
| 0x4bc0d1 | strtok | 50 | Thread-safe string tokenizer |
| 0x4a458c | longjmp_with_cleanup | 34 | CRT longjmp with optional cleanup callback |
| 0x4a45aa | seh_filter_invoke | 21 | Invoke SEH exception filter callback |
| 0x4c02e7 | LeaveCriticalSection_indexed | 23 | LeaveCriticalSection by index |
21. Graphics Pipeline
Graphics_DrawIndexedPrimitive (0x47dfb9, 25 xrefs)
- D3D DrawIndexedPrimitive wrapper via vtable dispatch at offset 0x2C
- Called from SceneObject_RenderFull for mesh rendering
🔗 Related Documents
RE Project
Hamsterball RE Project - Build Notes
Environment Setup
System
- OS: Linux (Ubuntu)
- Architecture: x86_64
- Wine: 9.0 installed, game launches but no display
Tools Installed
- Ghidra 12.0.4 at /opt/ghidra
- radare2 (r2) - CLI disassembler/analyzer
- pefile - Python PE parser
- Wine 9.0 - Windows compatibility layer
- Java 21 JDK
- GCC toolchain
Project Layout
~/hamsterball-re/
├── originals/
│ ├── installer/
│ │ ├── setup_hamsterball.exe (6.9 MB)
│ │ └── share_download.zip (15.4 MB)
│ └── installed/
│ ├── Hamsterball.zip (8.5 MB)
│ └── extracted/ (full game, 450 files)
├── analysis/
│ └── ghidra/
│ ├── HamsterballProject/ (Ghidra project, 20MB)
│ ├── functions.txt (r2 function list)
│ └── r2_functions.txt (r2 full function dump)
├── docs/
│ ├── RESEARCH_LOG.md
│ ├── FILE_HASHES.json
│ ├── FILE_FORMATS.md
│ ├── FUNCTION_MAP.md
│ ├── STRUCTS_AND_TYPES.md
│ ├── RUNTIME_ENVIRONMENT.md
│ └── BUILD_NOTES.md (this file)
├── reimpl/
│ ├── src/
│ ├── include/
│ ├── assets/
│ └── tools/
└── notes/
Key Findings So Far
Binary Analysis
- PE32 i386 executable, MSVC compiled
- Entry point: 0x004BB4C8
- 5 sections: .text, .rdata, .data, .data1, .rsrc
- 9 imported DLLs with ~200 function imports
- 1,869 functions identified by r2
- Window class: "AthenaWindow" (Raptisoft engine name)
- Uses Direct3D 8 for rendering
- Uses BASS library for audio (MO3 format music)
Key Function Addresses (CONFIRMED via r2)
fcn.00455380- Graphics::Initialize (calls Direct3DCreate8)- Sets debug strings at offset +0x8 of object: "Graphics::Initialize(1)" through "Graphics::Initialize(23)"
entry0 (0x40BB4C8)- CRT entry pointfcn.00453ed0- Graphics initialization sub-function- Cross-references to "AthenaWindow" at 0x46ba62, 0x46bb00, 0x46d218 (window creation)
Game Engine Architecture
- Engine name: "Athena" (derived from window class "AthenaWindow")
- Built by Raptisoft (John Raptis)
- C++ with MSVC, DirectX 8 era (2010 timestamp)
- Custom mesh format (not standard ASE, despite having ASE-style tokens)
- Debug strings preserved in release build
Asset Format Summary
- MESHWORLD: Binary level format with type strings
- MESH: Binary 3D mesh with embedded texture references
- MO3: Tracker music via BASS
- OGG: Sound effects
- Font: Custom bitmap font system with atlas PNGs
- CFG: Binary save format with 64-byte player name records
- XML: Jukebox.xml and RaceData.xml for game configuration
Next Steps
- Connect Ghidra MCP for interactive decompilation
- Map the game's main loop (WinMain → App::Initialize → game loop)
- Reverse Graphics::Initialize in detail
- Reverse the level loader (MESHWORLD parser)
- Begin C reimplementation scaffold
🔗 Related Documents
recon-analyzer
recon-analyzer
A tiny, program-agnostic first-pass analyzer for compiled binaries.
Usage
python3 recon-analyzer.py /path/to/binary
python3 recon-analyzer.py /path/to/binary --out report.md
python3 recon-analyzer.py /path/to/dir --recursive --out report.md
python3 recon-analyzer.py /path/to/binary --format json
What it does
- Hashes the target (MD5, SHA256)
- Computes entropy
- Runs
strings - Categorizes strings (paths, DLLs, APIs, file types, interesting keywords)
- Summarizes PE structure (if
pefileis installed) - Emits Markdown or JSON report
Requirements
- Python 3.10+
stringscommand available- Optional:
pefile(pip install pefile)
Example
python3 recon-analyzer.py ~/hamsterball-re/originals/installed/extracted/Hamsterball.exe --out /tmp/hb_recon.md
🔗 Related Documents
Reference Node (Ref) Loading S
Hamsterball Reference Node (Ref) Loading System
Overview
Hamsterball levels load "reference nodes" (refs) from MESHWORLD files. Each ref has a name (e.g. SPEEDCYLINDER, BONK, GEAR) that determines what game object gets created. The game uses a multi-pass code-gated dispatch system — not a file-driven registry — to decide which refs to instantiate. The full pipeline runs in the master level loading function (~0x0041C8xx), which makes sequential passes over the ref point list.
Key finding: Adding a ref name to a MESHWORLD file is necessary but not sufficient. The ref must ALSO be handled by one of the dispatch passes (SAFESPOT extractor, SIGN handler, or vtable[33] factory), or the game will silently skip it. To load any object ref into any level, you must patch the Board's vtable[33] to point to a factory that handles the desired ref names, since each level's factory only recognizes its own subset of ref names.
Complete Loading Pipeline
Board constructor
├─ 1. Creates LevelData (board+0x8AC, size 0x10D0) with MESHWORLD file path
│ LevelData parses MESHWORLD binary into AthenaLists:
│ • Section 1 ref points → LevelData+0x480+0x894
│ • Section 3 scene objects → mesh entities with name at +0x864
│
└─ 2. Loads sub-mesh MESHWORLD files into board+0x436C..0x4390
Master loading function (~0x0041C8xx)
├─ 3a. Iterates ref points, extracts SAFESPOT and START-DEBUG refs
├─ 3b. Conditional quality-dependent setup (scene+0x237 byte)
├─ 3c. Additional setup (0x0040BAA0, 0x0040C0F0)
├─ 3d. Scene_CreateSigns (0x0040C270) — "SIGN" prefix refs → StandsTipper objects
├─ 3e. Scene_CreateDynamicObjects (0x0040C430) — object refs via vtable[33] factory
│ ├─ Factory matches ref name via __strnicmp
│ ├─ Creates game object (Pendulum, Bonk, Gear, etc.)
│ ├─ Appends to board+0xCD4 and scene object lists
│ └─ Factory calls N: handler (0x0040C5D0) on created mesh entities
└─ 3f. N: handler processes entity names from Section 3:
• N:GOAL, N:TARPIT, N:WATER, N:SECRET, E:JUMP, E:BREAK, etc.
• Sets behavioral flags on board/scene/mesh
Architecture
1. MESHWORLD Ref Points (Section 1)
Each MESHWORLD file contains a Section 1 with "reference points" — named position markers stored as:
name(string, e.g.SPEEDCYLINDER,BONK,GEAR— stored WITHOUTN:prefix)position(3 floats: x, y, z at offsets +0x04, +0x08, +0x0C)rotation/scaledata (at offset +0x10)extra float(at offset +0x14, used for scale/size calculations)- Additional flags and color data
The ref points are parsed from the MESHWORLD binary and stored in an AthenaList at:
board+0x8AC→ LevelData object (0x10D0 bytes, created in Board constructor)LevelData+0x480→ SceneObjectSceneObject+0x894→ AthenaList of ref points (count at +0x898, array at +0x40C)
Each ref entry in the array has the structure:
+0x00: char* name // pointer to ref name string
+0x04: float posX
+0x08: float posY
+0x0C: float posZ
+0x10: rotation/scale data
+0x14: float extraScale // used in Timer_Init and position calculations
2. Scene_CreateDynamicObjects (0x0040C430) — The Central Dispatch
void __thiscall Scene_CreateDynamicObjects(int *board) // ECX = board
This function iterates over all ref points in the loaded MESHWORLD level data. For each ref:
- Reads the ref name string from
ref_entry+0x00(*puVar4) - Reads position/scale data from
ref_entry+0x04throughref_entry+0x14 - Calls
board->vtable[33](refName, &out_obj, &out_col, ref_entry)— i.e.(**(code**)(*board + 0x84))(name, &out1, &out2, refPtr)at address0x0040C4BA - If the factory returns a non-null object (
out_obj != NULL):- Initializes a
Timer(viaTimer_Initat 0x00457AD0) using a float fromref_entry+0x14 - Sets graphics position/scale from the ref entry position data
- Appends the object to
board+0xCD4(board-specific object list) viaAthenaList_Appendat 0x00453810 - Appends to the scene's SceneObject list (
LevelData+0x480+0x1C) - Calls the object's
vtable[0x58](Update setup) at 0x0040C531 - Calls the object's
vtable[0x54](Render setup) with the timer at 0x0040C53F - If a collision object was returned, appends it to
board+0x10ECandboard+0x8B0+0x18
- Initializes a
Ref entry structure (param_5 / puVar4):
[0x00]= name string pointer (char*)[0x04],[0x08],[0x0C]= position (x, y, z as floats)[0x10]= rotation data[0x14]= extra float parameter (used by Timer_Init and some factories)
3. Board Vtable Slot 33 (+0x84) — The Factory Method
Each Board subclass overrides vtable[33] with its own factory function. This function receives:
this= Board pointerparam_2(char*) = ref name string (e.g. "SPEEDCYLINDER", "BONK", "GEAR")param_3(out) = pointer to receive created visual objectparam_4(out) = pointer to receive created collision objectparam_5(int) = pointer to the full ref entry (for position/scale extraction)
The factory uses __strnicmp(refName, "KEYWORD", len) to match ref names to object constructors. Unmatched refs are silently ignored — the factory simply returns null pointers.
4. The N: Prefix — TWO Separate Systems
There are two independent dispatch systems for MESHWORLD data:
System A: Ref Points (Section 1) → vtable[33] Factory Dispatch
- Ref names stored WITHOUT
N:prefix:SPEEDCYLINDER,BONK,GEAR,LIFTER, etc. - Processed by
Scene_CreateDynamicObjects→board->vtable[33]factory - Factory uses
__strnicmp(refName, "SPEEDCYLINDER", len)— bare name comparison - Creates game OBJECTS (visual + collision entities)
System B: Entity Names (Section 3) → N:/E: Prefix Handler (0x0040C5D0)
- Entity names stored WITH
N:orE:prefix:N:GOAL,N:TARPIT,E:JUMP,E:BREAK - Entity name is stored at
mesh_entity+0x864duringLevel_LoadMeshes/CreateMeshBuffer - Processed by the N:/E: handler at
0x0040C5D0, which is called from within the vtable[33] factories - Sets behavioral FLAGS on the board/scene (e.g. tar pit behavior, flag wave effects, jump zones, breakable surfaces)
- Does NOT create objects — modifies existing mesh entities
The N: handler (0x0040C5D0) processes these prefixes:
N:SECRET,N:UNLOCKSECRET— secret level unlock flagsN:GOAL— goal/finish markerN:TARPIT— tar pit collision behaviorN:WATER— water surface effectN:NOCONTROL— disables ball control in this areaN:BRIDGE,N:SWIRL,N:WHEELEMBED,N:WATERWHEEL— mesh behavior modifiersN:MACE,N:TRAPDOOR— object-specific behaviorsN:JUMPFIRST,N:JUMPSECOND— jump pad sequencingN:WAVY,N:SQUAREWOBBLY— wavy surface behaviorN:BUMPER,N:BUMPER%d— bumper assignmentN:SAWTEETH,N:SPINNY— saw/spinner behaviorN:EXTRATIME,N:SPEEDCYLINDER— time/speed zone markersN:SPINNER,N:NEONPLATFORM— platform behaviorN:BUMP,N:TENBONUS1,N:TENBONUS2— bonus point markersN:GLASS— breakable glass surfaceN:ONGEAR,N:ONROTATOR— gear/rotator attachmentN:BOUNCE— bounce surfaceN:MOUSETRAP— mouse trap behaviorE:JUMP,E:BREAK,E:ACTION,E:LIMIT,E:TRAJECTORY— event triggersONCE,TRUE,SCORE,X,Y,Z,PIPEBONK,POPOUT,ZIP— modifiers
The handler is called from 25 sites within the vtable[33] factory functions. Each factory calls the handler after creating the object, passing the mesh entity as this (ECX) and two additional arguments. The handler reads the entity name from mesh_entity+4 → +0x864 (the entity name set during CreateMeshBuffer when parsing MESHWORLD Section 3).
Complete list of entity names processed by the N:/E: handler (verified from binary):
| Entity Name | Behavior |
|---|---|
| N:SECRET | Secret/unlock mechanics |
| N:UNLOCKSECRET | Unlock secret areas |
| E:NODIZZY | Disable dizzy effect |
| TIME | Timer object |
| E:SAFESWITCH | Safe switch trigger |
| E:LIMIT | Boundary kill plane |
| E:BREAK | Breakable surface |
| E:JUMP | Jump pad |
| E:ACTION | Action trigger |
| ONCE, TRUE, YES | Boolean modifiers |
| SCORE | Scoring object |
| E:TRAJECTORY | Trajectory modifier |
| N:NOCONTROL | Disable control |
| N:WATER | Water effect |
| N:TARPIT | Tar pit effect |
| DROPIN | Pipe drop-in |
| PIPEBONK | Pipe collision |
| POPOUT | Pipe pop-out |
| ZIP | Zip/boost effect |
| Goal! | Goal text popup |
Call site example (at 0x0040D380, Dizzy factory):
MOV EAX, [ESP+0x0C] ; load ref entry from stack
PUSH EDI ; push board pointer (param_3)
PUSH EAX ; push ref entry (param_2)
MOV ECX, ESI ; ECX = mesh entity (this for __thiscall)
CALL 0x0040C5D0 ; N: handler(mesh_entity, ref_entry, board)
5. The Scene+0x23C Quality Gate
Many (but not all) factory branches check *(int *)(*(int *)(board + 0x878) + 0x23c) != 0 before creating objects. This field is the graphics quality setting.
Quality-gated refs (only created when quality is non-zero/high):
- TIPPER, GLUEBIE, MACE, FAN, SAWBLADE, SPINNY, BONK, POPCYLINDER, MAGNIFYER
- BLOCKDAWG1, BLOCKDAWG2, BLOCKDAWG3
- E:ALERTSAW2, E:BRANCH
Always-created refs (created regardless of quality setting):
- BRIDGE, WATERWHEEL, SWIRL, SMASHER1-2, CATAPULT, DRAWBRIDGE, WINDMILL, TRAPDOOR, CHOMPER, TURRET
- LIFTER, SPEEDCYLINDER, TIMEBUTTON, NEONPLATFORM, DFLOOR1-4, TRODE, BELL, JUDGE
- SAW, SAW2, FALLOUT1, WOBBLY1-7, WAVY1, LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM
- BBRIDGE1-2, SECRET, SECRETUNLOCK, BADBALL, PILLAR, TARBUBBLE, MOUSETRAP, LAUNCH
40 quality gate checks found across the factory function range (0x40A000–0x419000), verified by binary pattern matching: MOV EAX, [reg+0x23C]; TEST EAX, EAX; JZ skip_creation.
Race vs Arena — Two Separate Board Systems
Hamsterball has TWO independent Board systems: one for Race mode and one for Arena mode. Each uses different Board constructors, different vtables, and different vtable[33] factories.
RACE Board System (0x41Cxxx constructors)
CORRECTED (June 2026): Previous version of this section swapped race and arena constructors. The 0x41Cxxx constructors create RACE boards (verified by decompiling all 30 constructors and reading board name strings: "Board (X)" + "X RACE"). The 0x422xxx constructors create ARENA boards ("ArenaBoard (X)" + "X ARENA").
Race Board constructors are at 0x41CA40–0x424C20. Each is called from a jump table at 0x42761C (15 entries, indexed by level_number - 1). The jump table is reached via JMP [EAX*4 + 0x42761C] at 0x427080.
Race Board vtables are at 0x4D1428–0x4D2298. The Race factories form an inheritance chain: each level-specific factory checks its own refs, then falls through to call the base factory 0x4133E0 (which handles PLATFORM, STANDS).
| # | Race Level | Constructor | Board Vtable | Factory Addr | Refs Handled (from decompilation) |
|---|---|---|---|---|---|
| 1 | Warm-up | 0x4224A0 | 0x4D1428 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 2 | Beginner | 0x422550 | 0x4D14F0 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 3 | Intermediate | 0x4226E0 | 0x4D15C0 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 4 | Dizzy | 0x422790 | 0x4D1680 | 0x4143D0 | SPINNY, MACE, CATAPULT, TURRET, LIFTER, FAN + base |
| 5 | Tower | 0x4228C0 | 0x4D1740 | 0x414680 | MACE, CATAPULT, TURRET, LIFTER, FAN + base |
| 6 | Up | 0x422B10 | 0x4D17F8 | 0x414A20 | LIFTER, FAN, WOBBLY + base |
| 7 | Neon | 0x424860 | 0x4D1EC8 | 0x4173B0 | NEONPLATFORM, DFLOOR, TRODE + base |
| 8 | Expert | 0x423060 | 0x4D18C8 | 0x414BD0 | FAN, WOBBLY + base |
| 9 | Odd | 0x423220 | 0x4D1980 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 10 | Toob | 0x4234E0 | 0x4D1A40 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 11 | Wobbly | 0x423690 | 0x4D1B18 | 0x415460 | WOBBLY, PILLAR, POPCYLINDER + base |
| 12 | Glass | 0x424B60 | 0x4D2048 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 13 | Sky | 0x423BF0 | 0x4D1BD8 | 0x415A30 | POPCYLINDER + base |
| 14 | Master | 0x424380 | 0x4D1C80 | 0x4133E0 | PLATFORM, STANDS (base only) |
| 15 | Impossible | 0x424EC0 | 0x4D2298 | 0x418760 | GEAR + base |
Note: The "Refs Handled" column above lists what the factory's decompiled code can match via __strnicmp. This is NOT the same as what refs actually exist in the MESHWORLD files — see docs/VERIFIED_REFS_BY_LEVEL.md for the ground-truth list of refs that are actually placed in each level's MESHWORLD file. Several refs the factories can handle (like PLATFORM, STANDS) are utility/system refs, not game objects.
Key insight: Levels 1, 2, 3, 9, 10, 12, 14 use ONLY the base factory — they handle only PLATFORM, STANDS, N:BUMPER in race mode. Their level-specific objects (tippers, bridges, etc.) are NOT created by vtable[33] in race mode — they are either part of the static level geometry or loaded via the Arena Board system.
Race constructor chain: Like the Arena constructors, Race Board constructors also form a chained function. Race L7 (Neon) loads Impossible sub-meshes, L12 (Glass) loads Impossible sub-meshes, and L14 (Master) loads Neon sub-meshes — all via conditional level-name checks (GLASSRACE, IMPOSSIBLERACE, NEONRACE).
ARENA Board System (0x422xxx constructors)
Arena Board constructors are at 0x4224A0–0x424EC0. Each is called from the Arena switch function at 0x426AB0 (jump table, 15 entries).
Arena Board vtables are at 0x4D04A8–0x4D21C0. The Arena factories handle many more ref types than Race factories — they create ALL interactive objects for Arena mode.
| # | Arena Level | Constructor | Board Vtable | Factory Addr | Refs Handled (verified from binary) |
|---|---|---|---|---|---|
| 1 | Warm-up Arena | 0x41CA40 | 0x4D04A8 | 0x419750 | (none — NoOp) |
| 2 | Beginner Arena | 0x4200E0 | 0x4D1098 | 0x419750 | (none — NoOp) |
| 3 | Intermediate Arena | 0x41CB20 | 0x4D05A0 | 0x40A550 | BRIDGE, TIPPER, WATERWHEEL, SWIRL, GLUEBIE, SMASHER1, SMASHER2 |
| 4 | Dizzy Arena | 0x41D060 | 0x4D0890 | 0x40A5F0 | TIPPER, WATERWHEEL, SWIRL, GLUEBIE, SMASHER1, SMASHER2 |
| 5 | Tower Arena | 0x41E340 | 0x4D0A08 | 0x40D7C0 | CATAPULT, MACE, DRAWBRIDGE, WINDMILL, TRAPDOOR, CHOMPER, BONK, FAN, SAWBLADE, BRIDGE, JUDGE, BELL |
| 6 | Up Arena | 0x420390 | 0x4D11A0 | 0x4117B0 | LIFTER, SPEEDCYLINDER, TIMEBUTTON, TarBubble, BRIDGE, TIPPER, BONK, BBRIDGE1-2, POPCYLINDER, BLOCKDAWG1-2, CATAPULT |
| 7 | Neon Arena | 0x424440 | 0x4D1DF0 | 0x416910 | NEONPLATFORM, DFLOOR1-4, TRODE, N:NEONPLATFORM, E:ZOOP, E:LIGHTSOFF, E:LIGHTSON, FLICKNING, N:BUMP, N:GLASS, N:TENBONUS1 |
| 8 | Expert Arena | 0x41EA40 | 0x4D0B00 | 0x40E250 | BONK, FAN, SAWBLADE, BRIDGE, JUDGE, BELL, E:SCORE, E:BELL, LIFTER, E:GRAVITY |
| 9 | Odd Arena | 0x41ED80 | 0x4D0BC0 | 0x40EC40 | LIFTER, E:GRAVITY, WOBBLY1-7, WAVY1, N:SQUAREWOBBLY, N:WAVY, SPINNY |
| 10 | Toob Arena | 0x41F4B0 | 0x4D0E78 | 0x40FB30 | SPINNY, FALLOUT1, BLOCKDAWG1-3, E:ALERTSAW2, E:BRANCH, N:SPINNY, N:SAWTEETH, N:BUMPER, PILLAR, MAGNIFYER |
| 11 | Glass Arena | 0x41F110 | 0x4D0D38 | 0x40F420 | WOBBLY1-7, WAVY1, N:SQUAREWOBBLY, N:WAVY, SPINNY, FALLOUT1, BLOCKDAWG1-3, E:ALERTSAW2, E:BRANCH, N:SPINNY |
| 12 | Wobbly Arena | 0x424A90 | 0x4D1F90 | 0x40AD80 | SMASHER1, SMASHER2, SECRETUNLOCK, SECRET, BADBALL |
| 13 | Sky Arena | 0x41F930 | 0x4D0FC8 | 0x410AD0 | POPCYLINDER, TRAPDOOR, N:BUMPER, VAC-IN, LIFTER, SPEEDCYLINDER, TIMEBUTTON |
| 14 | Master Arena | 0x4206D0 | 0x4D12B0 | 0x4121D0 | BRIDGE, TIPPER, BONK, BBRIDGE1-2, POPCYLINDER, BLOCKDAWG1-2, CATAPULT, GLUEBIE, N:SPINNER, N:BUMPER, E:LAUNCH |
| 15 | Impossible Arena | 0x424C20 | 0x4D21C0 | 0x417FE0 | LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM, N:BOUNCE, N:ONROTATOR, N:ONGEAR |
Level File → Board Mapping
The game's internal level numbers map to file paths differently for race vs arena. Race file numbers do NOT match level order — e.g. level5 = Expert Race (L8), level6 = odd race (L9).
| Race Level | File Path | Arena Level | File Path |
|---|---|---|---|
| L1 (Warm-up) | levels\level1 | L1 (Warm-up Arena) | levels\arena-WarmUp |
| L2 (Beginner) | levels\levelcascade | L2 (Beginner Arena) | levels\arena-beginner |
| L3 (Intermediate) | levels\level2 | L3 (Intermediate Arena) | levels\arena-intermediate |
| L4 (Dizzy) | levels\level3 | L4 (Dizzy Arena) | levels\arena-dizzy |
| L5 (Tower) | levels\level4 | L5 (Tower Arena) | levels\arena-tower |
| L6 (Up) | levels\levelup | L6 (Up Arena) | levels\arena-up |
| L7 (Neon) | levels\leveldark | L7 (Neon Arena) | levels\arena-neon |
| L8 (Expert) | levels\level5 | L8 (Expert Arena) | levels\arena-expert |
| L9 (Odd) | levels\level6 | L9 (Odd Arena) | levels\arena-Odd |
| L10 (Toob) | levels\level8 | L10 (Toob Arena) | levels\arena-Toob |
| L11 (Wobbly) | levels\level7 | L11 (Wobbly Arena) | levels\arena-Wobbly |
| L12 (Glass) | levels\levelglass | L12 (Glass Arena) | levels\arena-glass |
| L13 (Sky) | levels\level9 | L13 (Sky Arena) | levels\arena-Sky |
| L14 (Master) | levels\level10 | L14 (Master Arena) | levels\arena-Master |
| L15 (Impossible) | levels\levelimpossible | L15 (Impossible Arena) | levels\arena-impossible |
Complete Ref Name → Constructor Mapping
Verified against the actual MESHWORLD binary data (46 unique object types). Numbered suffixes (e.g. TIPPER01, GEAR02) are collapsed to their base type. See docs/VERIFIED_REFS_BY_LEVEL.md for the full verified data.
| Ref Name | Constructor | Alloc Size | Quality-Gated? | Race Levels Found In |
|---|---|---|---|---|
| BBRIDGE | BreakBridge_ctor | 0x1100 | No | Master |
| BELL | Tipper_Level_Ctor | 0x10E8 | No | Expert |
| BIGGEAR | Gear_ctor | 0x1514 | No | Impossible |
| BLOCKDAWG | Blockdawg_ctor | 0x1154 | Yes | Toob, Master |
| BONK | Bonk_ctor | 0x1200 | Yes | Expert, Master |
| BRIDGE | (returns pre-loaded mesh) | 0 | No | Intermediate, Expert, Master |
| CATAPULT | Catapult_ctor | 0x1108 | No | Tower, Master |
| CHOMPER | (position update only) | 0 | No | Tower |
| DFLOOR | ArenaStands_ctor | 0x1104 | No | Neon |
| DRAWBRIDGE | Glass_Level_ctor | 0x113C | No | Tower |
| FALLOUT1 | Stands_CtorCollisionV2 | 0x10E8 | No | Toob |
| FAN | (FAN + FANSLOW + FAN(SUPER)(UP) variants) | varies | varies | Expert |
| GEAR | Gear_ctor | 0x1514 | No | Impossible |
| GLUEBIE | Gluebie_ctor | 0x110C | Yes | Dizzy, Master |
| JUDGE | Gear_Level_ctor | 0x1100 | No | Expert |
| LAUNCH | (launch pad marker) | 0 | No | Odd |
| LIFTER | Stands_CtorWithCollision / Rotator_ctor_sound | 0x10FC / 0x10F4 | No | Up, Odd |
| LOOPER | Looper_ctor | 0x1500 | No | Impossible |
| MACE | CascadeStands_Ctor | 0x110C | Yes | Tower |
| MAGNIFYER | (magnifying glass object) | 0 | No | Sky |
| MOUSETRAP | GlassStands_Ctor | 0x10F8 | No | Intermediate, Master |
| NEONPLATFORM | Stands_CtorRotator | 0x10EC | No | Neon |
| PENDULUM | Pendulum_ctor | 0x1504 | No | Impossible |
| PILLAR | (static mesh + collision) | 0 | No | Sky |
| POPCYLINDER | PopCylinder_ctor / Platform_ctor | 0x10E8 / 0x10F4 | Conditional | Sky, Master |
| ROTATOR | Rotator_ctor | 0x1508 | No | Impossible |
| SAW | Stands_CtorCollision | 0x1110 | Yes | Toob |
| SAW-BREAK | (breakable saw marker) | 0 | No | Expert |
| SAW2 | Stands_CtorSpeedCylinder | 0x1118 | Yes | Toob |
| SAWBLADE | Sawblade_Level_Ctor | 0x111C | Yes | Expert |
| SIGN-TARPIT | (sign handler + tar pit data) | 0 | No | Dizzy |
| SMASHER | (position update only) | 0 | No | Glass |
| SPEEDCYLINDER | Pendulum_ctor | 0x150C | No | Up |
| SPINNY | Rotator_ctor | 0x1508 | No | Toob |
| SWIRL | (returns pre-loaded mesh) | 0 | No | Dizzy |
| TARBUBBLE | (tar bubble animation) | 0 | No | Dizzy, Master |
| TIMEBUTTON | Rotator_ctor_nosound | 0x10E8 | No | Up |
| TIPPER | Tipper_ctor | 0x1104 | Yes | Dizzy, Master |
| TRAPDOOR | GlassStands_Ctor / Rotator_ctor | 0x10F8 / 0x10F4 | No | Tower, Sky |
| TRODE | ArenaStands_ctor | 0x1104 | No | Neon |
| TURRET | Stands_ctor + CollisionLevel | 0x10D0 | No | Tower |
| WATERWHEEL | (returns pre-loaded mesh) | 0 | No | Dizzy |
| WAVY | Stands_CtorWithCollisionLevel | 0x1AE7C | No | Wobbly |
| WINDMILL | (returns pre-loaded mesh + collision) | 0 | No | Tower |
| WOBBLY | GameLevel_ctor | 0x1524 | No | Wobbly |
Special Ref Modifiers
Some refs support suffix modifiers checked via strstr():
(NOCOLLIDE)— on BRIDGE refs, suppresses collision object creationSLOW— on Expert's [TOW] refs, sets slow flagSUPER— on Expert's [TOW] refs, sets super flagUP— on Expert's [TOW] refs, initializes sound channels1,2— on SAWBLADE and BRIDGE refs, assigns to specific board slotsNEG— on BRIDGE refs in Expert, sets negative rotationTOUCH— on BIGGEAR refs, sets touch-activated flag
Verified Ref Names per Level
Confirmed by parsing the original MESHWORLD binary files with a spec-compliant parser. See docs/VERIFIED_REFS_BY_LEVEL.md for the full ground-truth data.
46 unique object types across 15 race levels. Refs listed below are Section 1 ref points only (object spawn markers). Utility refs (START, SAFESPOT, FLAG, BADBALL, SECRET, etc.) are omitted — see the standalone doc for the full list.
| # | Race Level | Object Refs (Section 1) |
|---|---|---|
| L1 | Warm-up | (none) |
| L2 | Beginner | (none) |
| L3 | Intermediate | BRIDGE, MOUSETRAP |
| L4 | Dizzy | SIGN-TARPIT, TARBUBBLE, GLUEBIE, TIPPER, WATERWHEEL, SWIRL |
| L5 | Tower | CATAPULT, TRAPDOOR, DRAWBRIDGE, MACE, WINDMILL, CHOMPER, TURRET |
| L6 | Up | SPEEDCYLINDER, LIFTER, TIMEBUTTON |
| L7 | Neon | DFLOOR, TRODE, NEONPLATFORM |
| L8 | Expert | BONK, FAN, FANSLOW, SAWBLADE, BRIDGE, SAW-BREAK, JUDGE, BELL |
| L9 | Odd | LIFTER, LAUNCH |
| L10 | Toob | SPINNY, SAW, FALLOUT1, SAW2, BLOCKDAWG |
| L11 | Wobbly | WOBBLY, WAVY |
| L12 | Glass | SMASHER |
| L13 | Sky | PILLAR, MAGNIFYER, POPCYLINDER, TRAPDOOR |
| L14 | Master | BBRIDGE, BLOCKDAWG, BONK, BRIDGE, CATAPULT, GLUEBIE, MOUSETRAP, POPCYLINDER, TIPPER, TARBUBBLE |
| L15 | Impossible | LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM |
Common utility refs in ALL levels: START (spawn point), SAFESPOT (respawn point), FLAG (checkpoint), BADBALL (AI ball).
How to Load Any Ref Into Any Level
Method 1: MESHWORLD-Only (Limited)
Adding a ref name to a MESHWORLD file alone will NOT cause an object to be created. The ref name must match a __strnicmp check in the Board's vtable[33] factory. If the factory doesn't handle that name, the ref is silently ignored.
Exception: Master Race's CreateLevelObjects (0x4121D0) factory handles the most ref types (BRIDGE, TIPPER, BONK, BBRIDGE1-2, POPCYLINDER, BLOCKDAWG1-2, CATAPULT, GLUEBIE). If the target level uses this factory, many refs will work from MESHWORLD alone.
Method 2: DLL Mod — Vtable Patch (Recommended)
Patch the target Board's vtable[33] entry (at vtable_addr + 0x84) to point to a combined factory function. The combined factory should:
- First try the original level's factory (call through the saved original pointer)
- If the original returns null, try factories from other levels
// Pseudo-code for combined factory
void __thiscall UniversalFactory(void* board, char* refName, void** outObj, void** outCol, int* refEntry) {
// Try original factory first
originalFactory(board, refName, outObj, outCol, refEntry);
if (*outObj != NULL) return;
// Try other level factories
CreateUpLevelObjects(board, refName, outObj, outCol, refEntry);
if (*outObj != NULL) return;
CreateExpertLevelObjects(board, refName, outObj, outCol, refEntry);
if (*outObj != NULL) return;
CreateMechanicalObjects(board, refName, outObj, outCol, refEntry);
// ... etc
}
Critical requirement: The Board must have the necessary sub-meshes pre-loaded. Each Board constructor loads specific MeshWorld files (e.g. Levels\Level3-Tipper, Levels\Level4-Catapult) into board+0x436C..0x4390 slots. Without the corresponding mesh, the factory may crash or produce invisible objects.
Method 3: DLL Mod — Hook Scene_CreateDynamicObjects
Hook the central dispatch at 0x0040C4BA. Before the original call to vtable[33], intercept the ref name and call any factory function directly. This is the most flexible approach:
// Hook at 0x0040C4BA, before the vtable[33] call
// The call site is at offset ~0x?? from function entry:
// (**(code **)(*param_1 + 0x84))(*puVar4, &local_58, &local_54, puVar4);
// Replace this indirect call with a direct call to your dispatcher
Method 4: Binary Patch — Swap Vtable Entry
Overwrite the 4 bytes at (board_vtable_addr + 0x84) with the address of a different level's factory. For example, to give Warm-up Race the Impossible factory:
- Patch
0x4D04A8 + 0x84=0x4D04ECfrom0x00419750to0x00417FE0
This is the simplest method but only gives you ONE level's factory at a time.
Sub-Mesh Preloading
Each Board constructor preloads specific MeshWorld files into board struct slots. The table below shows which sub-mesh files each Board constructor loads (verified by tracing string references in the binary):
Board Creation — Mode Determination
The game checks App+0x237 (byte) to determine whether to use Race Board or Arena Board:
; At ~0x4270AB
CMP [EAX+0x237], BL ; check App+0x237 (BL=0)
JZ skip_race ; if zero, skip race board creation
CALL 0x00426780 ; Race Board switch (jump table at 0x426AB0)
- App+0x237 = 1 (non-zero) → Race mode → Race Board constructors (0x422xxx)
- App+0x237 = 0 → Arena mode → Arena Board constructors (0x41Cxxx), called from
0x427080
The Race Board function (0x426780) uses a jump table at 0x426AB0 with 15 entries (indexed by level_number - 1). The Arena Board function (0x427080) uses a sequential switch at 0x427140 with sequential level IDs 1–14.
Board Constructor → Sub-Mesh File Mapping (Arena)
| Board | Constructor Area | Sub-Meshes Loaded (verified from binary) |
|---|---|---|
| Arena L1 | 0x4200E0 | LevelUp-Lifter, LevelUp-SpeedCylinder, LevelUp-Button, Level2-Bridge, Level10-2PBridge |
| Arena L2 | 0x41CB20 | Level2-Bridge, Level3-Tipper, Level3-WaterWheel, Level3-Swirl |
| Arena L3 | 0x41D060 | Level3-Tipper, Level3-WaterWheel, Level3-Swirl, Level3-Gluebie |
| Arena L4 | 0x41E340 | Level4-Catapult, Level4-Drawbridge, Level4-Mace, Level4-Windmill, Level4-Turret |
| Arena L5 | 0x420390 | LevelUp-Lifter, LevelUp-SpeedCylinder, LevelUp-Button, Level2-Bridge, Level10-2PBridge, Level3-Tipper, Level10-Bridge1-2, Level9-PopCylinder1-2, Level8-Blockdawg1-2, Level4-Catapult, Level3-Gluebie |
| Arena L6 | 0x424440 | LevelDark-NeonPlatform, LevelDark-DFloor1-4, LevelDark-Trode, LevelDark-Flickring |
| Arena L7 | 0x41EA40 | Level5-Bridge, Level7-Wobbly1 |
| Arena L8 | 0x41ED80 | Level7-Wobbly1-7 |
| Arena L9 | 0x41F4B0 | Level8-Spinny, Level8-Saw, Level8-Fallout, Level8-Blockdawg1-2, Level9-PopCylinder1-2, Level9-Trapdoor |
| Arena L10 | 0x41F110 | Level7-Wobbly1-7, Level8-Spinny, Level8-Saw, Level8-Fallout, Level8-Blockdawg1-2 |
| Arena L11 | 0x424A90 | LevelImpossible-Looper, Gear, BigGear, Rotator, Pendulum |
| Arena L12 | 0x41F930 | Level9-PopCylinder1-2, Level9-Trapdoor |
| Arena L13 | 0x4206D0 | Level2-Bridge, Level10-2PBridge, Level3-Tipper, Level10-Bridge1-2, Level9-PopCylinder1-2, Level8-Blockdawg1-2, Level4-Catapult, Level3-Gluebie |
| Arena L14 | 0x424C20 | LevelImpossible-Looper, Gear, BigGear, Rotator, Pendulum |
Factory → Board Sub-Mesh Slot Dependencies
Each Arena factory accesses specific board struct offsets (0x4344–0x4398) to retrieve pre-loaded sub-mesh data. If a slot is null (because the Board constructor didn't load that mesh), the factory will skip that ref type or crash.
| Factory | Board Slots Accessed | Refs Dependent on Slots |
|---|---|---|
| Beginner (0x40A550) | +0x436C, +0x4370, +0x4374 | BRIDGE, TIPPER, WATERWHEEL/SWIRL |
| Intermediate (0x40A5F0) | +0x436C, +0x4370, +0x4374 | TIPPER, WATERWHEEL, SWIRL, GLUEBIE |
| Dizzy (0x40D7C0) | +0x436C, +0x4370, +0x4378, +0x437C, +0x4390, +0x43A4, +0x43B0, +0x43B4 | CATAPULT, MACE, DRAWBRIDGE, WINDMILL, TRAPDOOR, TURRET (8 slots) |
| Tower (0x4117B0) | +0x436C, +0x4370, +0x4374, +0x4378, +0x4394, +0x4398 | LIFTER, SPEEDCYLINDER, TIMEBUTTON, BRIDGE, TIPPER, BONK |
| Neon (0x416910) | +0x4374, +0x4378, +0x437C, +0x4380, +0x4384, +0x4388, +0x438C, +0x4390 | NEONPLATFORM, DFLOOR1-4, TRODE, FLICKNING (8 slots) |
| Expert (0x40E250) | +0x436C, +0x4370, +0x4374 | BONK, SAWBLADE, BRIDGE, JUDGE, BELL |
| Odd (0x40EC40) | +0x436C, +0x4370, +0x4374, +0x4378, +0x437C | LIFTER, WOBBLY1-5, WAVY1 |
| Toob (0x40FB30) | +0x436C, +0x4370, +0x4374, +0x4378, +0x437C, +0x4380, +0x4384 | SPINNY, SAW, FALLOUT, BLOCKDAWG (7 slots) |
| Glass (0x40F420) | +0x436C, +0x4370, +0x4374, +0x4378, +0x437C, +0x4380, +0x4384 | WOBBLY1-7, WAVY1, SPINNY, FALLOUT, BLOCKDAWG (7 slots) |
| Wobbly (0x40AD80) | +0x4344 | SMASHER1-2 (1 slot) |
| Sky (0x410AD0) | +0x436C, +0x4374, +0x4378, +0x437C, +0x4380, +0x438C, +0x4390 | POPCYLINDER, TRAPDOOR (7 slots) |
| Master (0x4121D0) | +0x436C, +0x4370, +0x4394, +0x4398 | BRIDGE, TIPPER, BONK, BBRIDGE, POPCYLINDER, BLOCKDAWG, CATAPULT, GLUEBIE |
| Impossible (0x417FE0) | +0x436C, +0x4370, +0x4374, +0x4378, +0x437C | LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM |
Critical implication for the universal ref loader: When the DLL mod tries a factory from another level, that factory will access board slots that weren't loaded by the current level's constructor. Factories do NOT null-check their sub-mesh slots — they dereference the slot pointer directly after checking only that operator_new succeeded. If the slot is NULL, the factory will crash with an access violation.
The DLL mod must check sub-mesh slots BEFORE calling each factory, or pre-load the required sub-meshes. The slot dependency table above provides the exact offsets each factory accesses.
Arena Board Constructor Chain Architecture
The Arena Board constructors are NOT separate functions — they are chained sections of one large function starting at 0x41CA40. Each section:
- Compares the level name string (e.g.
"BEGINNERRACE","INTERMEDIATERACE","DIZZYRACE","UPRACE", etc.) - If the name matches, loads the appropriate sub-meshes for that level
- Falls through to the next section for the next level
The jump table at 0x42761C calls operator_new with different allocation sizes for each level (ranging from 17,260 to 25,752 bytes), then calls the constructor at 0x41CA40 which chains through all level sections.
Key implication: Since all Arena constructors share one function, ALL sub-meshes can be loaded by bypassing the level name comparison. The level name check (not separate function calls) is what prevents loading unrelated sub-meshes.
Complete Verified Sub-Mesh Loading Table
| Arena Level | Level Name String | Sub-Meshes Loaded |
|---|---|---|
| L1 Warm-up | (none — first section) | (no sub-meshes) |
| L2 Beginner | CASCADERACE | (no sub-meshes) |
| L3 Intermediate | INTERMEDIATERACE | Level2-Bridge, MOUSETRAP |
| L4 Dizzy | DIZZYRACE | Level3-Tipper, Level3-WaterWheel, Level3-Swirl, Level3-Gluebie |
| L5 Tower | (checks TOWERRACE) | Level4-Catapult, Level4-Drawbridge, Level4-Mace, Level4-Windmill, Level4-Turret |
| L6 Up | UPRACE | LevelUp-Lifter, LevelUp-SpeedCylinder, LevelUp-Button, Level2-Bridge, Level10-2PBridge, Level3-Tipper, Level10-Bridge1, Level10-Bridge2, Level9-PopCylinder1-2, Level8-Blockdawg1-2, Level4-Catapult, Level3-Gluebie |
| L7 Neon | (checks NEONRACE) | LevelDark-NeonPlatform, LevelDark-DFloor1-4, LevelDark-Trode, LevelDark-Flickning |
| L8 Expert | EXPERTRACE | Level5-Bridge |
| L9 Odd | ODDRACE | Level7-Wobbly1-7 |
| L10 Toob | TOOBRACE | Level8-Spinny, Level8-Saw, Level8-Fallout, Level8-Blockdawg1-2 |
| L11 Glass | GLASSRACE | Level7-Wobbly1-7 |
| L12 Wobbly | (no section found) | (no sub-meshes) |
| L13 Sky | SKYRACE | meshes/skypillar, meshes/magnifyingglass, Level9-PopCylinder1-2, Level9-Trapdoor, textures/clouds.png |
| L14 Master | MASTERRACE | Level2-Bridge, Level10-2PBridge, Level3-Tipper, Level10-Bridge1-2, Level8-Blockdawg1-2, Level4-Catapult, Level3-Gluebie, Level4-Mace, Level4-Catapult, Level4-Turret, Level7-Wobbly8 |
| L15 Impossible | IMPOSSIBLERACE | LevelImpossible-Looper, LevelImpossible-Gear, LevelImpossible-BigGear, LevelImpossible-Rotator, LevelImpossible-Pendulum, LevelImpossible-Gear |
All 46 sub-mesh files referenced by Arena Board constructors:
| Source Level | Sub-Mesh Files |
|---|---|
| Level2 | Level2-Bridge |
| Level3 | Level3-Tipper, Level3-WaterWheel, Level3-Gluebie, Level3-Swirl |
| Level4 | Level4-Catapult, Level4-Drawbridge, Level4-Mace, Level4-Windmill, Level4-Turret, Level4-Trapdoor1, Level4-Trapdoor2 |
| Level5 | Level5-Bridge, Level5-Bonk |
| Level6 | Level6-Lifter |
| Level7 (Neon) | Level7-Wobbly1-8, Level7-Wavy1 |
| Level8 | Level8-Spinny, Level8-Saw, Level8-Fallout, Level8-Blockdawg1-2 |
| Level9 (Sky) | Level9-Trapdoor, Level9-PopCylinder1-2 |
| Level10 | Level10-Bridge1-2, Level10-2PBridge |
| LevelDark (Neon) | LevelDark-NeonPlatform, LevelDark-DFloor1-4, LevelDark-Trode, LevelDark-Flickning |
| LevelUp | LevelUp-Lifter, LevelUp-SpeedCylinder, LevelUp-Button |
| LevelImpossible | LevelImpossible-Looper, LevelImpossible-Gear, LevelImpossible-BigGear, LevelImpossible-Rotator, LevelImpossible-Pendulum |
Note: "Level7" in the sub-mesh names = Neon Race (internal level 7), while "LevelDark" = the dark/neon-themed mesh set. Both are used by the Neon Arena constructor.
To load additional sub-meshes at runtime from a DLL mod:
// Load a mesh world into a free board slot at runtime
// MeshWorld_ctor address: find from Board constructor code
// operator_new: 0x00449E70
typedef void* (__cdecl *operator_new_t)(size_t);
typedef void* (__thiscall *MeshWorld_Load_t)(void* mesh, void* graphics, const char* path);
void load_submesh(void* board, int slot_offset, const char* path) {
operator_new_t op_new = (operator_new_t)0x00449E70;
// Allocate and load the mesh
void* mesh = op_new(0x10D0); // MeshWorld struct size
if (mesh) {
// Call MeshWorld constructor with graphics device and path
// The graphics device is at board+0x8AC (Scene pointer) → scene+0x480
void* scene = *(void**)((char*)board + 0x878);
void* graphics = *(void**)((char*)scene + 0x480);
// MeshWorld_Load(mesh, graphics, path);
// Store in board slot
*(void**)((char*)board + slot_offset) = mesh;
}
}
Master Race as the Universal Level
Master Race's Board constructor is the most inclusive — it preloads sub-meshes from 5 other levels (Tower, Toob, Dizzy, Intermediate, Wobbly) plus its own meshes. This is why Master's CreateLevelObjects factory (0x4121D0) can create 9 different object types. The universal ref loader DLL mod will work most reliably on Master Race levels, or on any level where you manually preload the required sub-meshes.
Scene_CreateSigns (0x0040C270)
A separate dispatch function handles SIGN refs. This function is not vtable-gated — it runs for all levels and creates StandsTipper objects for any ref starting with "SIGN". Special case: SIGN-TARPIT gets additional tar-pit data from scene+0x27C.
Complete Ref Name → Factory Reverse Index
This table shows every verified Section 1 ref name and which factory(ies) can create it. Only 46 verified object types are listed — refs that were previously listed here but do not exist in any original MESHWORLD file have been removed. See docs/VERIFIED_REFS_BY_LEVEL.md for the ground-truth source data.
Note: The "Arena Factories" column lists which Arena Board vtable[33] factory can create the object. The "Race Factories" column lists which Race Board factory handles it. Many refs are Arena-only (no Race factory handles them).
| Ref Name | Arena Factories | Race Factories | Required Slots |
|---|---|---|---|
| BBRIDGE | Master | — | +0x436C, +0x4370, +0x4394, +0x4398 |
| BELL | Expert | — | +0x436C, +0x4370, +0x4374 |
| BIGGEAR | Impossible | — | +0x436C, +0x4370, +0x4374, +0x4378, +0x437C |
| BLOCKDAWG | Toob, Master | — | +0x436C to +0x4384 |
| BONK | Dizzy, Expert, Master | — | varies |
| BRIDGE | Beginner, Dizzy, Tower, Expert, Master | — | +0x436C, +0x4370, +0x4374 |
| CATAPULT | Tower, Master | Race Tower | +0x436C, +0x4378 |
| CHOMPER | Tower | — | +0x436C, +0x4378, +0x43B0 |
| DFLOOR | Neon | — | +0x4374 to +0x4390 |
| DRAWBRIDGE | Tower | — | +0x436C, +0x4378, +0x4390 |
| FALLOUT1 | Toob, Glass | — | +0x436C to +0x4384 |
| FAN | Expert | Race Expert | varies |
| FANSLOW | Expert | — | +0x436C, +0x4370, +0x4374 |
| GEAR | Impossible | Race Impossible | +0x436C, +0x4370, +0x4374, +0x4378, +0x437C |
| GLUEBIE | Beginner, Intermediate, Master | — | +0x436C, +0x4370, +0x4374 |
| JUDGE | Expert | — | +0x436C, +0x4370, +0x4374 |
| LAUNCH | — | Race Odd | (race) |
| LIFTER | Tower, Expert, Odd | Race Up, Odd | varies |
| LOOPER | Impossible | — | +0x436C, +0x4370, +0x4374, +0x4378, +0x437C |
| MACE | Tower | Race Tower | +0x436C, +0x4378 |
| MAGNIFYER | Sky | — | +0x436C to +0x4384 |
| MOUSETRAP | Master | — | +0x436C |
| NEONPLATFORM | Neon | — | +0x4374 to +0x4390 |
| PENDULUM | Impossible | — | +0x436C to +0x437C |
| PILLAR | Sky | — | +0x436C |
| POPCYLINDER | Sky, Master | Race Sky | varies |
| ROTATOR | Impossible | — | +0x436C to +0x437C |
| SAW | Toob | — | +0x436C to +0x4384 |
| SAW-BREAK | Expert | — | +0x436C, +0x4370, +0x4374 |
| SAW2 | Toob | — | +0x436C to +0x4384 |
| SAWBLADE | Expert | — | +0x436C, +0x4370, +0x4374 |
| SIGN-TARPIT | — | (separate Scene_CreateSigns dispatch) | N/A |
| SMASHER | Glass | — | +0x4344 |
| SPEEDCYLINDER | Tower, Sky | Race Up | +0x436C, +0x4394, +0x4398 |
| SPINNY | Toob, Glass | — | +0x436C to +0x4384 |
| SWIRL | Beginner, Intermediate | — | +0x436C, +0x4370, +0x4374 |
| TARBUBBLE | Tower | — | +0x436C, +0x4394 |
| TIMEBUTTON | Tower, Sky | Race Up | +0x436C, +0x4394, +0x4398 |
| TIPPER | Beginner, Intermediate, Master | — | +0x436C, +0x4370, +0x4374 |
| TRAPDOOR | Tower, Sky | — | +0x436C, +0x4378, +0x4390 |
| TRODE | Neon | — | +0x4374 to +0x4390 |
| TURRET | — | Race Tower | (race) |
| WATERWHEEL | Beginner, Intermediate | — | +0x436C, +0x4370, +0x4374 |
| WAVY | Odd, Glass | Race Wobbly | +0x436C to +0x4384 |
| WINDMILL | Tower | — | +0x436C, +0x4378, +0x43A4 |
| WOBBLY | Odd, Glass | Race Wobbly | varies |
- Two independent Board systems: RACE Boards (constructors at 0x422xxx, vtables 0x4D1428–0x4D2298) and ARENA Boards (constructors at 0x41Cxxx, vtables 0x4D05A0–0x4D21C0). Race mode uses App+0x237=0; Arena mode uses App+0x237=1. Each has its own vtable[33] factory.
- vtable[33] dispatch:
Scene_CreateDynamicObjects(0x0040C430) iterates MESHWORLD Section 1 ref points, and for each ref, callsboard->vtable[33](board, refName, &outObj, &outCol, refEntry)at instruction0x0040C4BA. If the factory returns NULL, the ref is silently ignored. - Factory chain (Race): Race factories form an inheritance chain — each level-specific factory checks its own refs, then falls through to the base factory
0x4133E0which handlesPLATFORM,STANDS,N:BUMPER. - Factory chain (Arena): Arena factories are standalone — each handles its complete ref set without falling through. The most inclusive Arena factory is Master (13 ref types), followed by Dizzy (12 ref types) and Tower (13 ref types).
- Sub-mesh preloading: Each Board constructor preloads specific MESHWORLD files into board struct slots (+0x4344 through +0x43B4). Factories access these slots to get mesh data for object creation. Factories do NOT null-check these slots — calling a factory with unloaded slots causes an access violation crash.
- N:/E: handler (0x0040C5D0): A separate system that processes entity names from Section 3 objects to set behavioral flags (gravity modifiers, launch pads, bumpers, etc.). Called from 25 sites within the Arena factories.
- universal ref loader mod: A bass.dll proxy that hooks the vtable[33] dispatch at 0x0040C4BA and tries all 13 Arena factories in sequence, with per-factory sub-mesh slot safety checks. Allows loading any ref type into any level (limited by sub-mesh availability).
How to Load Any Ref Into Any Level
Option A — MESHWORLD mod only (limited):
For refs handled by the Race base factory (PLATFORM, STANDS), simply add the ref name to the MESHWORLD Section 1. No code changes needed. Note: the base factory handles very few object types — most objects require level-specific factory support.
Option B — MESHWORLD mod + DLL hook (any ref):
- Add the desired ref name to the level's MESHWORLD Section 1.
- Install the universal-ref-loader bass.dll proxy.
- The DLL mod will try all Arena factories when the level's own factory doesn't recognize the ref.
- Limitation: The ref's sub-mesh data must already be loaded in a board slot. If the ref requires a sub-mesh that wasn't loaded by the current Board constructor, the factory will be skipped (safety check prevents crash, but ref won't be created).
Option C — MESHWORLD mod + pre-loaded sub-meshes (full support):
- Add the ref name to the level's MESHWORLD Section 1.
- Pre-load the required sub-mesh by modifying the Board constructor to load additional MESHWORLD files (binary patch the constructor to add MeshWorld loading calls).
- Install the universal-ref-loader bass.dll proxy.
- All 46 verified ref types can now be loaded into any level.
🔗 Related Documents
Registry System
Hamsterball Registry System
Overview
Hamsterball stores all persistent settings (display, audio, unlocks, controls, best times) in the Windows Registry under HKEY_CURRENT_USER\Software\Raptisoft\Hamsterball.
The game wraps raw Win32 ADVAPI32 calls in a thin RegKey_* helper layer. This document covers the exact key path, value names, data types, App offsets, and how to read/write registry data the same way the engine does.
Last verified against: Hamsterball.exe (Athena engine, VS2003) via Ghidra decompilation.
Registry Key Path
HKEY_CURRENT_USER\Software\Raptisoft\Hamsterball
The path is built at runtime using the format string at 0x4D3978:
"Raptisoft\\%s"where%s="Hamsterball"
The game opens this key once at startup and caches the HKEY handle at App+0x54.
Win32 Registry API Imports
From ADVAPI32.dll (standard Windows registry API):
| Import # | API Name | Purpose |
|---|---|---|
| 480 | RegOpenKeyA |
Open existing key |
| 481 | RegOpenKeyExA |
Open with options |
| 459 | RegCreateKeyA |
Create if missing |
| 491 | RegQueryValueExA |
Read value |
| 504 | RegSetValueExA |
Write value |
| 456 | RegCloseKey |
Close handle |
Engine Wrapper Functions
The game does not call Win32 APIs directly from every save site. Instead it uses these wrapper helpers (addresses inferred from App_SaveAllConfig usage):
RegKey_Open — Open/Creates the Key
- Address: Inlined / called from
App_SaveAllConfig - Input:
int* reg_handle_ptr(points toApp+0x54) - Behavior: Calls
RegCreateKeyAto createSoftware\Raptisoft\Hamsterballif missing.
RegKey_WriteDWORD
- Input:
(void* handle, const char* name, DWORD value) - Behavior: Calls
RegSetValueExA(handle, name, 0, REG_DWORD, &value, 4)
RegKey_WriteBool
- Input:
(void* handle, const char* name, BYTE value) - Behavior: Same as
RegKey_WriteDWORDbut writes a 1-byteREG_DWORD(0 or 1).
Registry_SetValue
- Input:
(void* handle, const char* name, BYTE* data, DWORD size) - Behavior: Calls
RegSetValueExA(handle, name, 0, REG_BINARY, data, size)
RegKey_Close
- Input:
(int handle) - Behavior: Calls
RegCloseKey(handle)and invalidates the cached handle.
App Object Registry Offsets
The App struct (base at App or App_ptr) stores all values that are mirrored to the registry. Here are the confirmed offsets used by App_SaveAllConfig:
| Offset | Type | Registry Name | Description |
|---|---|---|---|
+0x054 |
HKEY |
(handle) | Cached registry key handle |
+0x238 |
BYTE |
RightButtonPause |
Right-click pauses the game |
+0x84C |
DWORD |
`MouseSensitivity`` | Mouse sensitivity (0–10 typical) |
+0x850 |
BYTE |
MirrorTournament |
Mirror-mode tournament flag |
+0x851 |
BYTE |
DizzyRace |
Unlock: Dizzy race |
+0x852 |
BYTE |
TowerRace |
Unlock: Tower race |
+0x853 |
BYTE |
UpRace |
Unlock: Up race |
+0x854 |
BYTE |
ExpertRace |
Unlock: Expert race |
+0x855 |
BYTE |
OddRace |
Unlock: odd race |
+0x856 |
BYTE |
ToobRace |
Unlock: Toob race |
+0x857 |
BYTE |
WobblyRace |
Unlock: Wobbly race |
+0x858 |
BYTE |
SkyRace |
Unlock: Sky race |
+0x859 |
BYTE |
MasterRace |
Unlock: Master race |
+0x85A |
BYTE |
DizzyArena |
Unlock: Dizzy arena |
+0x85B |
BYTE |
TowerArena |
Unlock: Tower arena |
+0x85C |
BYTE |
UpArena |
Unlock: Up arena |
+0x85D |
BYTE |
ExpertArena |
Unlock: Expert arena |
+0x85E |
BYTE |
OddArena |
Unlock: Odd arena |
+0x85F |
BYTE |
ToobArena |
Unlock: Toob arena |
+0x860 |
BYTE |
WobblyArena |
Unlock: Wobbly arena |
+0x861 |
BYTE |
SkyArena |
Unlock: Sky arena |
+0x862 |
BYTE |
MasterArena |
Unlock: Master arena |
+0x863 |
BYTE |
NeonRace |
Unlock: Neon race |
+0x864 |
BYTE |
GlassRace |
Unlock: Glass race |
+0x865 |
BYTE |
ImpossibleRace |
Unlock: Impossible race |
+0x866 |
BYTE |
NeonArena |
Unlock: Neon arena |
+0x867 |
BYTE |
GlassArena |
Unlock: Glass arena |
+0x868 |
BYTE |
ImpossibleArena |
Unlock: Impossible arena |
+0x86C |
BYTE[0x50] |
BestTime |
80-byte blob of best race times |
+0x8BC |
BYTE[0x50] |
Medals |
80-byte blob of medal bitmasks |
+0xB28 |
DWORD |
2PController1 |
2P mapping slot 1 |
+0xB2C |
DWORD |
2PController2 |
2P mapping slot 2 |
+0xB30 |
DWORD |
2PController3 |
2P mapping slot 3 |
+0xB34 |
DWORD |
2PController4 |
2P mapping slot 4 |
Save Function Reference
App_SaveAllConfig — 0x4284C0
The master save routine. Called on game exit and whenever settings change.
void __fastcall App_SaveAllConfig(void* app)
{
App_WriteDisplaySettings(app); // 0x?????? — saves resolution/quality
RegKey_Open(*(int*)(app + 0x54)); // Ensure key is open
// ---- Scalar settings ----
RegKey_WriteDWORD(app + 0x54, "MouseSensitivity", *(DWORD*)(app + 0x84C));
RegKey_WriteBool (app + 0x54, "MirrorTournament", *(BYTE* )(app + 0x850));
RegKey_WriteBool (app + 0x54, "RightButtonPause", *(BYTE* )(app + 0x238));
// ---- Race unlocks (12) ----
RegKey_WriteBool(app + 0x54, "DizzyRace", *(BYTE*)(app + 0x851));
RegKey_WriteBool(app + 0x54, "TowerRace", *(BYTE*)(app + 0x852));
RegKey_WriteBool(app + 0x54, "UpRace", *(BYTE*)(app + 0x853));
RegKey_WriteBool(app + 0x54, "ExpertRace", *(BYTE*)(app + 0x854));
RegKey_WriteBool(app + 0x54, "OddRace", *(BYTE*)(app + 0x855));
RegKey_WriteBool(app + 0x54, "ToobRace", *(BYTE*)(app + 0x856));
RegKey_WriteBool(app + 0x54, "WobblyRace", *(BYTE*)(app + 0x857));
RegKey_WriteBool(app + 0x54, "SkyRace", *(BYTE*)(app + 0x858));
RegKey_WriteBool(app + 0x54, "MasterRace", *(BYTE*)(app + 0x859));
RegKey_WriteBool(app + 0x54, "NeonRace", *(BYTE*)(app + 0x863));
RegKey_WriteBool(app + 0x54, "GlassRace", *(BYTE*)(app + 0x864));
RegKey_WriteBool(app + 0x54, "ImpossibleRace", *(BYTE*)(app + 0x865));
// ---- Arena unlocks (12) ----
RegKey_WriteBool(app + 0x54, "DizzyArena", *(BYTE*)(app + 0x85A));
RegKey_WriteBool(app + 0x54, "TowerArena", *(BYTE*)(app + 0x85B));
RegKey_WriteBool(app + 0x54, "UpArena", *(BYTE*)(app + 0x85C));
RegKey_WriteBool(app + 0x54, "ExpertArena", *(BYTE*)(app + 0x85D));
RegKey_WriteBool(app + 0x54, "OddArena", *(BYTE*)(app + 0x85E));
RegKey_WriteBool(app + 0x54, "ToobArena", *(BYTE*)(app + 0x85F));
RegKey_WriteBool(app + 0x54, "WobblyArena", *(BYTE*)(app + 0x860));
RegKey_WriteBool(app + 0x54, "SkyArena", *(BYTE*)(app + 0x861));
RegKey_WriteBool(app + 0x54, "MasterArena", *(BYTE*)(app + 0x862));
RegKey_WriteBool(app + 0x54, "NeonArena", *(BYTE*)(app + 0x866));
RegKey_WriteBool(app + 0x54, "GlassArena", *(BYTE*)(app + 0x867));
RegKey_WriteBool(app + 0x54, "ImpossibleArena", *(BYTE*)(app + 0x868));
// ---- Binary blobs (best times & medals) ----
Registry_SetValue(app + 0x54, "BestTime", (BYTE*)(app + 0x86C), 0x50);
Registry_SetValue(app + 0x54, "Medals", (BYTE*)(app + 0x8BC), 0x50);
// ---- 2P controller mappings ----
RegKey_WriteDword(app + 0x54, "2PController1", *(DWORD*)(app + 0xB28));
RegKey_WriteDword(app + 0x54, "2PController2", *(DWORD*)(app + 0xB2C));
RegKey_WriteDword(app + 0x54, "2PController3", *(DWORD*)(app + 0xB30));
RegKey_WriteDword(app + 0x54, "2PController4", *(DWORD*)(app + 0xB34));
RegKey_Close(*(int*)(app + 0x54));
}
Load Function Reference
LoadOrSaveConfig — 0x4279F0
Called during startup to read (or create) the configuration. It mirrors the save structure but uses RegQueryValueExA instead of RegSetValueExA.
(Ghidra decompilation is large; the relevant registry-read logic is structurally identical to App_SaveAllConfig but reads into the same App offsets instead of writing.)
Display Settings
Resolution, color depth, texture quality, and full-screen mode are saved by App_WriteDisplaySettings (called from App_SaveAllConfig). The exact value names for display settings live in the string table near 0x4D5EB8 ("Resolution: %d x %d") and are written as a separate sub-key or value blob.
How to Add Custom Registry Values (Modding Guide)
If you are injecting code or writing a trainer/mod, follow the same pattern the engine uses:
1. Open the key
// app = pointer to App object (passed to most __fastcall functions)
int hKey = *(int*)((BYTE*)app + 0x54);
RegKey_Open(hKey); // wrapper at inferred address; or call RegCreateKeyA yourself
2. Write a scalar
// DWORD example
DWORD myValue = 42;
RegSetValueExA(hKey, "MyModValue", 0, REG_DWORD, (BYTE*)&myValue, 4);
// BOOL example (same as DWORD but 0/1)
BYTE myFlag = 1;
RegSetValueExA(hKey, "MyModFlag", 0, REG_DWORD, &myFlag, 4);
3. Write binary data
BYTE myBlob[64] = { ... };
RegSetValueExA(hKey, "MyModData", 0, REG_BINARY, myBlob, sizeof(myBlob));
4. Close
RegKey_Close(hKey); // or RegCloseKey(hKey)
Important Notes
- The game uses
REG_DWORDfor both true DWORDs and booleans (notREG_SZ). - Binary blobs (
BestTime,Medals) useREG_BINARYwith exact 80-byte (0x50) sizes. - The registry handle at
App+0x54is cached; do not leak it—always close after use. - Display settings are saved separately; if you are adding display-related values, hook
App_WriteDisplaySettingsinstead.
String Table References (Binary Offsets)
| String | Binary Offset | Usage |
|---|---|---|
"Raptisoft\\%s" |
0x4D3978 |
Registry key format |
"MouseSensitivity" |
0x4D2898 |
Save/load |
"MirrorTournament" |
0x4D2884 |
Save/load |
"BestTime" |
0x4D274C |
Save/load |
"Medals" |
0x4D2744 |
Save/load |
"2PController1" |
0x4D2734 |
Save/load |
"Resolution: %d x %d" |
0x4D5EB8 |
Display UI |
"Texture Quality" |
0x4D8780 |
Display UI |
Quick Reference: Reading from Outside the Game
If you want to read Hamsterball settings from an external tool (Python / C# / etc.):
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Raptisoft\Hamsterball")
# Read a DWORD (e.g., MouseSensitivity)
val, typ = winreg.QueryValueEx(key, "MouseSensitivity")
print(val) # int
# Read a binary blob (e.g., BestTime)
val, typ = winreg.QueryValueEx(key, "BestTime")
print(len(val)) # 80 bytes
winreg.CloseKey(key)
See Also
App_SaveAllConfig— master save routine @0x4284C0LoadOrSaveConfig— startup load/create @0x4279F0OptionsMenu_RenderControls— control binding UI @0x42E840SoundDevice_ReadVolume— audio registry read @0x466570SoundDevice_dtor— audio registry save @0x4668A0
🔗 Related Documents
Reimplementation API Strategy
Reimplementation API Strategy
Core Principle: Same APIs as Original
The goal is to open-source Hamsterball, not rewrite it with different APIs.
Every call must match what the original binary does:
- Direct3D 8 (not OpenGL/SDL) for rendering
- DirectInput8 (not SDL input) for keyboard/mouse/joystick
- BASS.dll (shipped alongside, like original) for audio
- Win32 API (not SDL window) for window management
Original binary is PE32 i386 (32-bit), so our build targets 32-bit Windows.
Wine handles D3D8→OpenGL translation for Linux users automatically.
WinMain Flow (0x4278E0)
WinMain → RegisterClassExA("AthenaWindow") → CreateWindowExA(800,600)
→ App_Initialize_Full(0x429530)
→ App_Initialize(0x46BB40): DirectInput8Create, BASS_Init, D3D8 creation
→ LoadCursorA("BLANKCURSOR")
→ SetDisplayMode(800,600) via graphics vtable
→ SetRenderState(D3DRS_LIGHTING=TRUE, SHADEMODE=3)
→ Graphics_FindOrCreateTexture("shadow.png")
→ MusicChannel_LoadAndAppend("music\\music.mo3")
→ LoadJukebox("jukebox.xml")
→ RegKey_ReadDword("PlayCount", default=20)
→ 4x InputDevice_SetType (kb=1, mouse=2, joy1=4, joy2=5)
→ App_Run(0x46BD80): game loop (PeekMessage/TranslateMessage/DispatchMessage)
→ App_Shutdown(0x46DB10)
Build Target
- Compiler: i686-w64-mingw32-gcc (32-bit Windows)
- Link: -ld3d8 -ldinput8 -ldsound -ldxguid -lwinmm -lole32 -loleaut32
- BASS.dll: Shipped alongside exe (same as original — it's a third-party lib, not game code)
- D3D8: System DLL on Windows, Wine provides it on Linux
D3D8 Calls to Replicate (from Ghidra)
App_Initialize (0x46BB40):
Direct3DCreate8()
IDirect3D8_CreateDevice(D3DDEVTYPE_HAL, ...)
SetRenderState(D3DRS_LIGHTING, TRUE)
SetRenderState(D3DRS_SHADEMODE, D3DSHADE_GOURAUD=3)
DirectInput8 Calls
DirectInput8Create(hInstance, DIRECTINPUT_VERSION, &IID_IDirectInput8A, ...)
CreateDevice(GUID_SysKeyboard)
CreateDevice(GUID_SysMouse)
EnumDevices(DI8DEVCLASS_GAMECTRL, ...) // for joysticks
BASS Audio Calls
BASS_Init(-1, 44100, 0, 0, NULL) // default device, 44.1kHz
BASS_StreamCreateFile(FALSE, "music\music.mo3", ...)
BASS_ChannelPlay(stream, TRUE)
🔗 Related Documents
Reimplementation Plan
Hamsterball Reimplementation Plan: Working Game with Comprehensive Tests
For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
Goal: Build a working Hamsterball reimplementation that loads all original assets the same way and calls the same APIs, with a comprehensive test suite to verify correctness and diagnose problems.
Architecture: Replace the current stub-heavy codebase with a layered, testable engine. Each module matches the original binary's subsystem (filesystem, mesh, meshworld, texture, audio, input, physics, scene, camera, UI/menu, game state). All asset loading uses the original game's file formats and directory structure. SDL2+OpenGL replaces D3D8, SDL_mixer replaces BASS. Tests verify each layer against known binary behavior.
Tech Stack: C11, SDL2, OpenGL 2.1, SDL_mixer, SDL_image, GLU, CMake, bash test harness
Phase 1: Test Infrastructure & Asset Loading (Foundation)
Task 1: Create test harness infrastructure
Objective: Set up a test runner that can run unit tests and integration tests, report pass/fail, and capture output.
Files:
- Create:
reimpl/tests/test_runner.sh - Create:
reimpl/tests/test_helpers.h - Create:
reimpl/tests/test_helpers.c - Modify:
reimpl/CMakeLists.txt
Step 1: Create test runner shell script
#!/bin/bash
# test_runner.sh - Run all tests, report results
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="${1:-$SCRIPT_DIR/../build}"
PASS=0; FAIL=0; TOTAL=0
run_test() {
local name="$1" cmd="$2"
TOTAL=$((TOTAL+1))
printf " TEST %-50s " "$name"
if output=$($cmd 2>&1); then
PASS=$((PASS+1))
echo "PASS"
else
FAIL=$((FAIL+1))
echo "FAIL"
echo "$output" | sed 's/^/ /'
fi
}
# Unit tests (no display needed)
for t in "$BUILD_DIR"/test_*; do
[ -x "$t" ] && run_test "$(basename $t)" "$t"
done
# Integration tests need display
export DISPLAY=${DISPLAY:-:99}
for t in "$BUILD_DIR"/itest_*; do
[ -x "$t" ] && run_test "$(basename $t)" "timeout 10 $t"
done
echo ""
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
[ $FAIL -eq 0 ]
Step 2: Create C test helper macros
// test_helpers.h - Simple C test macros
#ifndef TEST_HELPERS_H
#define TEST_HELPERS_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
static int g_tests_run = 0, g_tests_pass = 0, g_tests_fail = 0;
#define TEST(name) void name(void)
#define RUN_TEST(name) do { \
printf(" %-50s ", #name); \
g_tests_run++; \
name(); \
g_tests_pass++; \
printf("PASS\n"); \
} while(0)
#define ASSERT_EQ(a, b) do { \
if ((a) != (b)) { \
g_tests_fail++; g_tests_pass--; \
printf("FAIL\n %s != %s (line %d): %ld != %ld\n", \
#a, #b, __LINE__, (long)(a), (long)(b)); \
return; \
} \
} while(0)
#define ASSERT_FEQ(a, b, eps) do { \
if (fabs((a)-(b)) > (eps)) { \
g_tests_fail++; g_tests_pass--; \
printf("FAIL\n %s != %s (line %d): %f != %f\n", \
#a, #b, __LINE__, (double)(a), (double)(b)); \
return; \
} \
} while(0)
#define ASSERT_NEQ(a, b) do { if ((a) == (b)) { \
g_tests_fail++; g_tests_pass--; \
printf("FAIL\n %s == %s (line %d)\n", #a, #b, __LINE__); return; \
} } while(0)
#define ASSERT_NULL(p) do { if ((p) != NULL) { \
g_tests_fail++; g_tests_pass--; \
printf("FAIL\n %s not NULL (line %d)\n", #p, __LINE__); return; \
} } while(0)
#define ASSERT_NOT_NULL(p) do { if ((p) == NULL) { \
g_tests_fail++; g_tests_pass--; \
printf("FAIL\n %s is NULL (line %d)\n", #p, __LINE__); return; \
} } while(0)
#define ASSERT_STR_EQ(a, b) do { if (strcmp((a),(b)) != 0) { \
g_tests_fail++; g_tests_pass--; \
printf("FAIL\n %s != %s (line %d): \"%s\" != \"%s\"\n", \
#a, #b, __LINE__, (a), (b)); return; \
} } while(0)
#define TEST_MAIN() int main(void) { \
printf("Running tests...\n"); \
test_all(); \
printf("\n%d/%d passed\n", g_tests_pass, g_tests_run); \
return g_tests_fail > 0 ? 1 : 0; \
}
#endif
Step 3: Add CMake test targets
# At end of CMakeLists.txt, add:
enable_testing()
# Find all test source files
file(GLOB TEST_SOURCES "tests/test_*.c")
file(GLOB ITEST_SOURCES "tests/itest_*.c")
foreach(src ${TEST_SOURCES})
get_filename_component(name ${src} NAME_WE)
add_executable(${name} ${src} src/level/meshworld_parser.c src/level/mesh_parser.c)
target_include_directories(${name} PRIVATE ${CMAKE_SOURCE_DIR}/include)
target_link_libraries(${name} m)
add_test(NAME ${name} COMMAND ${name})
endforeach()
# Integration tests need SDL+GL
foreach(src ${ITEST_SOURCES})
get_filename_component(name ${src} NAME_WE)
add_executable(${name} ${src} src/level/meshworld_parser.c src/level/mesh_parser.c)
target_include_directories(${name} PRIVATE ${CMAKE_SOURCE_DIR}/include ${SDL2_INCLUDE_DIRS})
target_link_libraries(${name} ${SDL2_LIBRARIES} ${OPENGL_LIBRARIES} ${GLEW_LIBRARIES} m)
endforeach()
Step 4: Run to verify
cd ~/hamsterball-re/reimpl/build && cmake .. && make test_runner 2>/dev/null; bash ../tests/test_runner.sh
Expected: 0 tests (empty harness), exit 0.
Step 5: Commit
cd ~/hamsterball-re && git add reimpl/tests/ reimpl/CMakeLists.txt && git commit -m "reimpl: test harness infrastructure - shell runner, C macros, CMake targets"
Task 2: Asset path configuration and filesystem module
Objective: Create a centralized asset path resolver that mirrors the original game's directory structure.
Files:
- Create:
reimpl/include/core/filesys.h - Create:
reimpl/src/core/filesys.c - Create:
reimpl/tests/test_filesys.c
Original game reads from these locations (relative to exe directory):
Levels/— .MESHWORLD level filesMeshes/— .MESH model filesTextures/— .PNG texture files (also .BMP, .JPG)Sounds/— .WAV sound filesMusic/— .MO3 music filesData/— game data (racedata.xml, jukebox.xml, HS.CFG)Fonts/— font.description + font PNGs
Step 1: Write filesystem module
// filesys.h
#ifndef FILESYS_H
#define FILESYS_H
#include <stddef.h>
typedef struct { char base_path[512]; } filesys_t;
void filesys_init(filesys_t *fs, const char *exe_path);
void filesys_set_base(filesys_t *fs, const char *path);
size_t filesys_resolve(const filesys_t *fs, const char *subdir, const char *name, const char *ext, char *out, size_t out_size);
int filesys_file_exists(const char *path);
int filesys_read_file(const char *path, uint8_t **data, size_t *size);
#endif
Step 2: Write test
// test_filesys.c
#include "test_helpers.h"
#include "core/filesys.h"
TEST(test_filesys_init) {
filesys_t fs;
filesys_init(&fs, "/path/to/Hamsterball.exe");
ASSERT_STR_EQ(fs.base_path, "/path/to");
}
TEST(test_filesys_resolve) {
filesys_t fs;
filesys_init(&fs, "/games/hamsterball");
char out[512];
size_t len = filesys_resolve(&fs, "Levels", "Level1", "MESHWORLD", out, sizeof(out));
ASSERT_NEQ(len, 0);
ASSERT_STR_EQ(out, "/games/hamsterball/Levels/Level1.MESHWORLD");
}
TEST(test_filesys_exists) {
// Use a file we know exists
ASSERT_EQ(filesys_file_exists("/dev/null"), 1);
ASSERT_EQ(filesys_file_exists("/nonexistent/file.xyz"), 0);
}
void test_all(void) {
RUN_TEST(test_filesys_init);
RUN_TEST(test_filesys_resolve);
RUN_TEST(test_filesys_exists);
}
TEST_MAIN()
Step 3: Implement, build, test, commit.
Task 3: MESHWORLD parser tests against all 87 original levels
Objective: Verify the existing parser works on ALL original level files and report statistics.
Files:
- Create:
reimpl/tests/test_meshworld.c
Step 1: Write test that iterates all levels
// test_meshworld.c
#include "test_helpers.h"
#include "level/meshworld_parser.h"
#include "core/filesys.h"
#include <dirent.h>
#define LEVELS_DIR "../originals/installed/extracted/Levels"
static int count_levels(void) {
DIR *d = opendir(LEVELS_DIR);
if (!d) return 0;
int count = 0;
struct dirent *ent;
while ((ent = readdir(d))) {
if (strstr(ent->d_name, ".MESHWORLD")) count++;
}
closedir(d);
return count;
}
TEST(test_all_levels_parse) {
DIR *d = opendir(LEVELS_DIR);
ASSERT_NOT_NULL(d);
int parsed = 0, total = 0;
struct dirent *ent;
while ((ent = readdir(d))) {
if (!strstr(ent->d_name, ".MESHWORLD")) continue;
total++;
char path[512];
snprintf(path, sizeof(path), "%s/%s", LEVELS_DIR, ent->d_name);
mw_level_t *level = meshworld_parse_file(path);
if (level && level->object_count > 0) parsed++;
if (level) meshworld_free(level);
}
closedir(d);
printf("(%d/%d parsed) ", parsed, total);
ASSERT_EQ(parsed, total); // All must parse
ASSERT_EQ(total, 87); // Known count
}
TEST(test_level1_objects) {
mw_level_t *level = meshworld_parse_file(LEVELS_DIR "/Level1.MESHWORLD");
ASSERT_NOT_NULL(level);
ASSERT_NEQ(level->object_count, 0);
// Level1 has known START objects
int starts = 0;
for (int i = 0; i < level->object_count; i++) {
if (level->objects[i].type == MW_OBJ_START) starts++;
}
ASSERT_NEQ(starts, 0);
meshworld_free(level);
}
TEST(test_arena_levels_exist) {
const char *arenas[] = {"Arena-WarmUp", "Arena-Beginner", "Arena-Dizzy",
"Arena-Expert", "Arena-Glass", "Arena-Impossible"};
for (int i = 0; i < 6; i++) {
char path[512];
snprintf(path, sizeof(path), "%s/%s.MESHWORLD", LEVELS_DIR, arenas[i]);
mw_level_t *level = meshworld_parse_file(path);
ASSERT_NOT_NULL_MSG(level, arenas[i]);
meshworld_free(level);
}
}
void test_all(void) {
RUN_TEST(test_all_levels_parse);
RUN_TEST(test_level1_objects);
RUN_TEST(test_arena_levels_exist);
}
TEST_MAIN()
Step 2: Build and run: cd build && make test_meshworld && ./test_meshworld
Step 3: Fix any parse failures, commit.
Task 4: MESH parser tests against all 33 original meshes
Objective: Same as Task 3 but for .MESH model files.
Files:
- Create:
reimpl/tests/test_mesh.c
Step 1: Test all meshes parse, verify vertex counts match known values.
Step 2: Build, run, fix, commit.
Task 5: Texture loading module (PNG/BMP/JPG via SDL_image)
Objective: Load texture files the same paths the original game uses (Textures/ dir, with -mip1 variants).
Files:
- Create:
reimpl/include/graphics/texture.h - Create:
reimpl/src/graphics/texture.c - Create:
reimpl/tests/itest_texture.c
Original game texture loading from 0x476770 (Texture_Create):
- Strip extension from filename
- Try:
{path}{name}-mip1.{fmt}then{path}{name}.{fmt} - Try formats: PNG, JPG, BMP in order
- Store in texture cache (AthenaList at Graphics+0x2E4)
Step 1: Implement texture module with SDL_image backend.
Step 2: Write integration test that loads known textures from original game dir.
Step 3: Build, test, commit.
Task 6: Material and mesh rendering integration
Objective: Load a .MESH file, apply its material, load its texture, and render it with OpenGL.
Files:
- Create:
reimpl/include/graphics/mesh_render.h - Create:
reimpl/src/graphics/mesh_render.c - Create:
reimpl/tests/itest_mesh_render.c
Step 1: Implement mesh GL upload (VBO from vertex array + texture).
Step 2: Integration test: load Sphere.MESH, load HamsterBall.png, render to offscreen, verify no GL errors.
Step 3: Build, test, commit.
Task 7: Audio loading module (WAV via SDL_mixer)
Objective: Load .WAV sound files from original game's Sounds/ directory.
Files:
- Create:
reimpl/include/audio/sound.h - Create:
reimpl/src/audio/sound.c - Create:
reimpl/tests/itest_audio.c
Original game at 0x459660 (Sound_LoadOggOrWav): tries .ogg then .wav fallback.
At 0x459810 (Sound_GetNextChannel): circular buffer channel allocator.
Step 1: Implement WAV loading with SDL_mixer.
Step 2: Test: load 5 known sounds, verify they play without error.
Step 3: Build, test, commit.
Task 8: Font loading module
Objective: Load font.description + font PNG files from original Fonts/ directory.
Files:
- Create:
reimpl/include/graphics/font.h - Create:
reimpl/src/graphics/font.c - Create:
reimpl/tests/itest_font.c
Font_Load at 0x457130. Font format: text .description file mapping char codes to UV rects in font PNG atlas.
Step 1: Implement font loader.
Step 2: Test: load showcardgothic16 (used by splash screen), verify glyph metrics.
Step 3: Build, test, commit.
Phase 2: Game Subsystems
Task 9: Input module with key rebinding
Objective: Full DirectInput8 replacement with SDL keyboard/mouse/joystick, 6 rebindable actions.
Files:
- Modify:
reimpl/src/input/input.c - Modify:
reimpl/include/input/input.h - Create:
reimpl/tests/itest_input.c
Original input: KeyboardDevice at 0x46E250, 6 key bindings at +0x143-0x148.
Ball_GetInputForce at 0x46EC30: mode 1=keyboard, 2=mouse, 4-7=joystick.
Step 1: Implement configurable key mapping with SDL scancode support.
Step 2: Test: simulate key events, verify input state changes.
Step 3: Commit.
Task 10: Physics engine with correct constants
Objective: Implement ball physics with the exact constants from the original binary, replacing placeholder values.
Files:
- Modify:
reimpl/src/physics/physics.c - Modify:
reimpl/include/physics/physics.h - Create:
reimpl/tests/test_physics.c
Original constants (from data section):
- Ball radius: 35.0f (0x420C0000) — NOT 3.0f
- max_speed: 5000.0f (at Ball+0x188)
- speed_scale: (at Ball+0x18C)
- Gravity: -0.15f default
- Friction: 0.95f (_DAT_004CF4C0)
- Y damp: 0.8f (_DAT_004CF434)
- Speed friction: 0.99f (_DAT_004CF4B8)
Step 1: Replace all placeholder constants with correct binary values.
Step 2: Write unit tests: apply force → verify position after N steps matches expected trajectory.
Step 3: Commit.
Task 11: Collision system - mesh collision
Objective: Implement the core collision detection using the spatial tree system from the original.
Files:
- Create:
reimpl/include/physics/collision.h - Create:
reimpl/src/physics/collision.c - Create:
reimpl/tests/test_collision.c
Key original functions:
- Collision_TraverseSpatialTree (0x465EF0): octree traversal
- Ball_AdvancePositionOrCollision (0x4564C0): core physics+collision
- AABB_ContainsPoint (0x4580D0)
- Mesh_FindClosestCollision (0x465D90)
Step 1: Implement AABB, spatial tree, ray-mesh collision.
Step 2: Test: sphere vs known triangle, verify hit point.
Step 3: Commit.
Task 12: Camera system
Objective: Camera follow system matching original behavior.
Files:
- Create:
reimpl/include/graphics/camera.h - Create:
reimpl/src/graphics/camera.c
Original: CameraLookAt (0x413280), Level_SelectCameraProfile (0x40ACA0).
Camera follows ball with lerp, configurable height/distance per level.
Step 1: Move camera code from physics.c into camera.c. Add CameraLookAt targets from level data.
Step 2: Commit.
Task 13: Scene graph / object system
Objective: Scene graph matching the original's object lifecycle: add, update, render, remove.
Files:
- Create:
reimpl/include/scene/scene.h - Create:
reimpl/src/scene/scene.c
Original: Scene_AddObject (0x469990, 77 xrefs), GameUpdate (0x469CF0), Scene_RenderAllObjects (0x45E0E0).
Step 1: Implement SceneObject base, Scene container, update/render dispatch.
Step 2: Commit.
Task 14: Game state machine
Objective: Title screen, menu, race countdown, race, results — matching original app state flow.
Files:
- Create:
reimpl/include/core/gamestate.h - Create:
reimpl/src/core/gamestate.c
Original flow: WinMain→App_Initialize→App_ShowMainMenu→[menu dispatch]→App_StartRace→Scene_SpawnBallsAndObjects→3-2-1-GO→racing→Scene_HandleBallFinish→results
Step 1: Implement state machine with transitions.
Step 2: Commit.
Phase 3: Integration & Rendering
Task 15: Level renderer - mesh geometry from MESHWORLD
Objective: Actually render the mesh geometry stored in MESHWORLD files (not just object markers).
Files:
- Create:
reimpl/include/graphics/level_render.h - Create:
reimpl/src/graphics/level_render.c
The existing meshworld_parser extracts object positions but not the triangle geometry section. Need to extend the parser to read vertex/face data and upload to GL.
Step 1: Extend meshworld_parser to read Section 5 (vertex array) and face data.
Step 2: Implement GL VBO upload from parsed vertex data.
Step 3: Test: render Level1 with actual geometry visible.
Step 4: Commit.
Task 16: 3-pass render pipeline (opaque, alpha, shadow)
Objective: Match original's 3-pass render: opaque → alpha → shadow (Scene_RenderAllObjects 0x45E0E0).
Files:
- Modify:
reimpl/src/graphics/renderer.c
Step 1: Sort scene objects by render pass type. Render in correct order with depth/stencil states.
Step 2: Commit.
Task 17: Ball rendering with MESH model
Objective: Render the ball using the actual Sphere.MESH + HamsterBall.png texture, matching Ball_Render (0x402860).
Files:
- Modify:
reimpl/src/physics/physics.c
Step 1: Load Sphere.MESH at startup. Render with texture, rolling animation.
Step 2: Commit.
Task 18: UI rendering with fonts
Objective: Render text overlays (timer, speed, menu) using loaded fonts.
Files:
- Create:
reimpl/include/graphics/ui_render.h - Create:
reimpl/src/graphics/ui_render.c
Original: Font_DrawGlyph (0x457440), UI_DrawTextCentered (0x409C60), UI_DrawTextShadow (0x4012C0).
Step 1: Implement glyph quad rendering from font atlas.
Step 2: Commit.
Task 19: End-to-end integration test
Objective: Automated test that runs the game through a complete scenario.
Files:
- Create:
reimpl/tests/itest_e2e.c
Step 1: Test: init → load level → spawn ball → apply input → verify ball moves → verify camera follows → verify render completes → shutdown. All headless with GL offscreen.
Step 2: Commit.
Phase 4: Asset Compatibility Verification
Task 20: Asset compatibility test suite
Objective: Comprehensive test that verifies ALL original assets load correctly.
Files:
- Create:
reimpl/tests/test_asset_compat.c
Test matrix:
- All 87 .MESHWORLD levels parse with >0 objects
- All 33 .MESH models parse with >0 vertices
- All 229 textures load without error
- All 63 sounds load and play
- Font files load
- RaceData.xml parses
- Music files load
Step 1: Implement, run, fix any failures.
Step 2: Commit.
Task 21: Save/load config compatibility
Objective: Read/write HS.CFG in the same format as the original.
Original: LoadConfig (0x42AE80), SaveConfig (0x42B6E0).
Files:
- Create:
reimpl/include/core/config_rw.h - Create:
reimpl/src/core/config_rw.c - Create:
reimpl/tests/test_config.c
Step 1: Implement, test with original HS.CFG if present.
Step 2: Commit.
Key Verification Commands
# Build everything
cd ~/hamsterball-re/reimpl/build && cmake .. && make -j$(nproc)
# Run all unit tests (no display needed)
make test
# Run integration tests (needs X)
DISPLAY=:99 make test
# Run game
DISPLAY=:99 ./hamsterball --level ../originals/installed/extracted/Levels/Level1.MESHWORLD
# Asset compatibility test
./test_asset_compat
# Full test suite
bash ../tests/test_runner.sh
🔗 Related Documents
Rendering Iteration Log (Sessi
Rendering Iteration Log (Sessions 50+)
Goal
Pixel-perfect visual parity between D3D8 reimplementation and original Hamsterball (2004).
Reference Screenshots
reimpl/reference-screenshots/original/05_level2_intermediate.png— Level2 (Intermediate)reimpl/reference-screenshots/original/06_warmup2.png— Level1 (Warm Up)
Iteration History
v5 (pre-session)
- Wall shadow too dark (deep blue), walls too dim overall
- Floor too dark, sky close but slightly off
v6 — Wall shadow lightened, lit_factor increased
- Wall lit=(0.80,0.92,1.0), shadow=(0.35,0.50,0.73)
- lit_factor = 0.85NdotL1 + 0.15NdotL2
- Vision result: walls (125,155,170) vs original (185,230,255) — STILL TOO DARK
v7 — Target-matched colors approach
- Rewrote to define output lit/shadow colors directly matching original
- Wall lit=(0.73,0.90,1.0), shadow=(0.35,0.50,0.73) → (89,128,186)
- Floor lit=(0.96,0.96,1.0), shadow=(0.47,0.55,0.78)
- Vision: walls (125,155,170) still too dark, floor too blue
v8 — Gamma curve added
- Added pow(lit_factor, 0.55) for steeper falloff
- Vision: contrast 2/10, wall shadow (110,140,175) vs orig (65,95,145) — too bright
- Root cause: fill light (L2) always adds brightness even to shadow faces
v9 — Fill light removed, deeper shadows
- Set fill light weight to 0.0 (shadow color encodes ambient)
- Wall shadow=(0.18,0.25,0.38), lit_factor=pow(x,0.45)
- Floor shadow=(0.70,0.78,1.0) — pale lavender
- Sky: (85,120,215) deep royal blue
- Vision: shadow (82,116,153) — closer but room for improvement
v10 — Shadow values tuned for zero-fill-light model
- Wall shadow=(0.24,0.35,0.49) → at lit_factor=0: (61,89,125) ≈ original (62,88,124) ✓
- lit_factor=pow(x,0.35) — aggressive gamma
- Floor bright nearly white, floor shadow lavender
- Pink checker: warm pink alternate squares
- Blue checker: light blue alternate squares
- Vision: contrast 4/10, shadows still reported as (110,140,175)
v11-v14 — Checker pattern investigation
- Checker code WAS running but pattern invisible in screenshots
- Root cause: per-vertex Gouraud shading cannot show square checker patterns
because D3D interpolates smoothly between vertex colors - If all 3 vertices of a triangle are on the same checker cell → uniform triangle
- Tried 2x UV scale (u*2.0) — still invisible
- Also found: D3DTSS_COLOROP was set to MODULATE for textured geoms,
which meant the (broken) BMP texture was overriding vertex colors
→ Changed to SELECTARG1+DIFFUSE for all geoms - Still no visible checker pattern because per-vertex approach is fundamentally limited
v15 — Programmatic checker textures (BREAKTHROUGH)
- Created 128x128 D3D textures via CreateTexture+LockRect at runtime
- PinkChecker: white + pale pink (0xFFE0D8EA / 0xFFFFF8FF)
- BlueChecker: white + light blue (0xFFD0E0FF / 0xFFFFF8FF)
- Set D3DTSS_COLOROP=MODULATE, COLORARG1=TEXTURE, COLORARG2=DIFFUSE
→ Per-pixel texture × per-vertex lighting gives visible checker! - Removed per-vertex checker code (no longer needed)
- Vision: checkerboard IS now visible on floor surfaces!
Current Color Targets (v15)
Walls (non-textured geoms)
| State | RGB | Float values |
|---|---|---|
| Lit | (172,222,254) target → actual ~(185,230,255) | (0.72,0.90,1.0) |
| Shadow | (62,88,124) target → actual ~(61,89,125) | (0.24,0.35,0.49) |
Floor (textured geoms)
| State | RGB | Float values |
|---|---|---|
| Lit | (245,245,255) | (0.96,0.96,1.0) |
| Shadow | (180,200,255) | (0.70,0.78,1.0) |
Sky
- Clear color: D3DCOLOR_RGBA(85,120,215) — deep royal blue
Lighting formula
NdotL1 = 0.35*nx + 0.85*ny + 0.35*nz (primary: above-left)
NdotL2 = -0.3*nx + -0.6*ny + -0.3*nz (fill: unused, weight=0)
raw_factor = 1.0 * NdotL1 + 0.0 * NdotL2
lit_factor = pow(raw_factor, 0.35)
final_color = shadow + (lit - shadow) * lit_factor
Checker textures
- Programmatic 128×128, 8×8 grid (16px cells)
- MODULATE mode: texture_RGB × vertex_RGB per pixel
- Pink: alternate cells are pale pink (0xE0D8EA) vs near-white (0xFFF8FF)
- Blue: alternate cells are light blue (0xD0E0FF) vs near-white (0xFFF8FF)
Remaining Issues
-
Wall shadow mid-tones still slightly bright — vision reports (100,135,175) for
partially-shadowed faces vs original (62,88,124). The gamma curve pow(x,0.35)
may need further tuning, or the shadow base values need slight reduction. -
Floor checker contrast — pattern IS visible at v15 but very subtle.
Need to increase color difference between checker squares.
Pink and blue alternate squares should be more distinctly colored. -
Missing visual elements:
- GO! arrow (red arrow decal)
- HUD timer (showcardgothic72 font + timerblot.png)
- Start pad (green circle: Decal-Start.png)
- Railing/pipes (purple pipe geometry)
- Ball transparency (hamster inside glass sphere)
-
Level2-specific: bluebrick.png geom isn't getting checker texture
(texture name detection only matches "Checker" not "brick").
Need to decide if bluebrick wall geoms should get any special treatment.
Key Architectural Decision
Target-matching approach: Instead of simulating the full D3D material×lighting
pipeline (which produces wrong colors on llvmpipe), we define lit/shadow output
colors that directly match the original game's appearance. The lit_factor interpolates
between them using a steep gamma curve. This is the inverse of standard rendering
but produces correct visual results.
llvmpipe Texture Bug
Wine/llvmpipe cannot render BMP/PNG-loaded D3D8 textures. Workaround: create
programmatic D3D textures (CreateTexture+LockRect) at runtime which DO render.
This is used for checker patterns via MODULATE blending.
🔗 Related Documents
Rendering Pipeline
Hamsterball Rendering Pipeline
Overview
The rendering pipeline is a 3-pass system: opaque objects first, then alpha-
blended objects, then shadow/depth objects. It uses D3D8 Direct3D with
custom sorting and z-buffer interleaving.
Pipeline Entry Points
App_Run (0x46BD80)
└─ App::Render() [vtable call]
└─ Graphics_BeginFrame (0x453B50)
└─ Graphics_RenderScene (0x454BC0)
├─ Graphics_SetupLights
├─ Gfx_SetViewMatrix
├─ Matrix_ComputeFrustum (0x4762B0)
├─ 8 render callbacks at Graphics+0x710
├─ D3DRS_ZENABLE, ZFUNC, ZWRITEENABLE restore
└─ Graphics_SetViewportZ
└─ Scene_RenderAllObjects (0x45E0E0)
├─ Pass 1: Opaque objects (flag 0x85F, 0x860, 0x862, 0x863 all 0)
├─ Pass 2: Alpha objects (flag 0x860=1)
├─ Pass 3: Shadow objects (flag 0x85F=1)
└─ Cleanup callbacks
└─ Graphics_PresentOrEnd (0x455A90)
└─ IDirect3DDevice8::Present
Graphics_RenderScene (0x454BC0)
Core D3D8 scene setup:
- SetupLights — configure D3D lights
- Copy matrices — World (Graphics+0x224), View (Graphics+0x264), Projection (Graphics+0x2A4)
- SetViewMatrix — apply view transform, compute frustum
- D3D SetTransform —
device->vtable[0x94](0x100, &projection)— set projection matrix - Copy back world matrix, recompute frustum
- 8 render callbacks — function pointers at Graphics+0x710..0x72C
- Restore render states — ZENABLE (0x8B), ZFUNC, ZWRITEENABLE (0x1C)
- SetViewportZ — restore Z range from Graphics+0x73C/0x740
- Final callbacks — Graphics+0x5C vtable[0x88] and vtable[0x78]
Graphics Object Matrix Offsets
| Offset | Size | Description |
|---|---|---|
| +0x224 | 64B (4x4) | World matrix |
| +0x264 | 64B (4x4) | View matrix |
| +0x2A4 | 64B (4x4) | Projection matrix |
Scene_RenderAllObjects (0x45E0E0)
Signature: Scene_RenderAllObjects(this, do_sorting, render_mode)
Sorting Phase (param_1 != 0)
- Clear 3 lists: shadow list (this+0x488), alpha list (this+0x8A0), deferred list (this+0xCB8)
- If ball rendering enabled (Scene+0x480→+0x434): call
Ball_InitRenderState
Object Classification
Each scene object has flags at these offsets:
| Flag | Offset | Meaning |
|---|---|---|
| 0x85F | +0x85F | Shadow caster |
| 0x860 | +0x860 | Alpha blended |
| 0x861 | +0x861 | Depth bias (z-fight fix) |
| 0x862 | +0x862 | Deferred render |
| 0x863 | +0x863 | Skip rendering |
Pass 1: Opaque Objects (all flags = 0)
For objects where no flags are set:
- Set render context:
obj+0x83C = Graphics+0x7C4(current frame counter) - Compute material address:
obj_index * 0x50 + mesh_base_ptr(at mesh+0x28) - Call
Graphics_ApplyMaterialAndDraw(graphics, material_address) - If ball mesh NOT in cutscene mode (
Scene+0x480→+0x10C4 == 0):- Iterate material list (obj+0x424), set D3D stream source per sub-material
- device->vtable[0x118](5, stride, offset) — SetStreamSource
- If ball IS in cutscene: set vertex declaration and draw
Pass 2: Alpha Objects (flag 0x860 = 1)
Objects with alpha flag are collected into this+0x8A0 list:
- Disable Z-write:
D3DRS_ZENABLE = 0(device vtable[200](0xE, 0)) - For each alpha object:
- Apply material and draw
- Handle sub-materials same as opaque pass
- Re-enable Z-write:
D3DRS_ZENABLE = 1(device vtable[200](0xE, 1))
Pass 3: Shadow Objects (flag 0x85F = 1)
Objects with shadow flag are collected into this+0x488 list:
- Adjust projection for depth bias:
proj_x += bias,proj_y += bias(DAT_004CF308) - Enable stencil:
D3DRS_STENCILENABLE = 1(device vtable[200](0x37, 1)) - For each shadow object:
- If depth-bias flag (0x861) set: toggle Z-write mid-render
- Apply material and draw
- Disable stencil:
D3DRS_STENCILENABLE = 3(device vtable[200](0x37, 3)) - Restore projection:
proj_x -= bias,proj_y -= bias - Re-enable Z if needed
Post-Render Cleanup
Iterate scene children at this+0x480→+0x428, call vtable[0x48](1, 1) for each
(child cleanup callback).
Render Pass D3D State Changes
| Pass | Z-write | Z-test | Stencil | Alpha blend |
|---|---|---|---|---|
| Opaque | ON | ON | OFF | OFF |
| Alpha | OFF | ON | OFF | ON |
| Shadow | ON | ON | ON | OFF (depth bias) |
SceneObject Render Flags (at object+offset)
| Offset | Flag | Description |
|---|---|---|
| +0x83C | uint | Frame counter (set to Graphics+0x7C4 each render) |
| +0x858 | uint | Vertex declaration index (for cutscene mode) |
| +0x850 | uint | Vertex buffer offset (for SetStreamSource) |
| +0x861 | byte | Depth bias flag — toggles Z-write during shadow pass |
Key Render Functions
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x453B50 | Graphics_BeginFrame | 39 | Begin D3D frame, clear backbuffer |
| 0x454BC0 | Graphics_RenderScene | — | Full scene setup (lights, matrices, frustum) |
| 0x45E0E0 | Scene_RenderAllObjects | 33 | 3-pass object render (opaque→alpha→shadow) |
| 0x455D60 | Graphics_DrawScreenRect | 63 | 2D rectangle via TLVERTEX tristrip |
| 0x455110 | Graphics_ApplyMaterialAndDraw | 17 | Set material + draw primitive |
| 0x455A90 | Graphics_PresentOrEnd | — | D3D Present or EndScene |
| 0x454A30 | Gfx_SetViewMatrix | — | Set D3DTS_VIEW matrix |
| 0x453900 | Graphics_Clear | — | D3D Clear (target+Z or Z+stencil) |
| 0x4539A0 | Graphics_SetupFog | — | Linear fog: FOGSTART, FOGEND, FOGCOLOR |
| 0x460450 | Scene_RenderBallShadow | 38 | Ball shadow with depth bias pass |
| 0x460DA0 | Scene_RenderFrame | 38 | Full scene render with z-buffer interleaving |
| 0x454190 | Graphics_SetRenderMode | — | Set shading mode, vertex shader, render states |
Graphics Object Key Offsets
| Offset | Type | Description |
|---|---|---|
| +0x07C | IDirect3D8* | D3D8 interface |
| +0x154 | IDirect3DDevice8* | D3D device |
| +0x224 | float[16] | World matrix (4x4) |
| +0x264 | float[16] | View matrix (4x4) |
| +0x2A4 | float[16] | Projection matrix (4x4) |
| +0x2E4 | AthenaList* | Texture cache |
| +0x5C | void** | Secondary render vtable (callbacks 0x78/0x88) |
| +0x70C | byte | Z-write state cache (0=off, 1=on) |
| +0x710 | void*[8] | 8 render callback function pointers |
| +0x730 | uint | Cached ZENABLE state |
| +0x734 | byte | Cached ZWRITEENABLE state |
| +0x738 | uint | Cached ZFUNC state |
| +0x748 | void* | Frustum data (for Matrix_ComputeFrustum) |
| +0x790 | float | Projection X offset (for depth bias) |
| +0x794 | float | Projection Y offset (for depth bias) |
| +0x7C4 | uint | Frame counter (incremented each render) |
| +0x7C8 | uint | Render call counter |
| +0x7CC | uint | Sub-material draw counter |
Depth Bias Value
DAT_004CF308 is a small float offset added to the projection matrix X/Y during
the shadow render pass. This pushes shadow geometry slightly forward in clip
space to prevent z-fighting with the floor geometry.
Level Rendering Pipeline
Level_UpdateAndRender (0x40B600)
6-phase render for level geometry and objects:
- Build visible_list from primary (+0x29D4) and secondary (+0x3204) ball object lists
- Opaque pass: AlphaTest OFF, iterate both lists calling vtable0x1C per object
- Alpha pass: AlphaTest ON, iterate both lists calling vtable0x1C per object
- Waypoint arrow: If race active and not game-over, show next waypoint arrow (timer=0.45s)
- Visible list render: iterate combined visible_list, call vtable0x08 per object
- Ball shadows: If ripple_list has entries, render shadows for all balls
Level_RenderObjects (0x40B570)
Transparent pass renderer:
- Graphics_BeginFrame(gfx, 0)
- level->vtable0x4C - render level mesh (terrain geometry)
- Graphics_BeginFrame(gfx, 0)
- For each object in visible_list: vtable0x0C (RenderTransparent)
Scene_RenderFrame (0x60DA0)
Per-frame scene update with vertex buffer construction:
- Set scene+0x10C4 = 1 (render active)
- If root scene: allocate MeshWorld + transform buffer
- Sprite update: call vtable[0x3C] per sprite
- Triangle strip construction: For each mesh object, build zigzag strips:
- Alternating vertex triples (degenerate-triangle strip pattern)
- 0x20 bytes per vertex (pos + normal + texcoord)
- SpriteAnim_SetRange for each object
- Font_RenderToTextureComplex for text→texture
- Mesh_SaveAndFree + free temp MeshWorld
Scene_CheckPath (0x57EC0)
Ring topology pathfinder on 360-cell (0x167=359) circular grid.
Used by Ball_Update for track-snapping collision.
- Returns 1 = forward reachable, -1 = backward reachable, 0 = unreachable
- Two walkers: forward (+1) and backward (-1), wrap at 0↔359
Scene_SpawnBallsAndObjects (0x41C5B0)
Level startup: spawn player balls + all interactive objects:
- For each start entry: lookup "START%d-%d" in hash table for position
- Ball_ctor2(0xC60 bytes) at position, radius=26.0, max_speed=5.0, gravity=0.5
- 1-player random start for race types 5/11/12/14
- Scan SAFESPOT/SAFEPOS entries
- Tournament/demo: CreateBadBall + CreateMouseTrap
- CreateSecretObjects + Scene_CreateFlags + Scene_CreateSigns + Scene_CreateDynamicObjects
D3D8 Device Vtable Dispatch Map
The game uses IDirect3DDevice8 COM vtable calls throughout. Key vtable offsets:
| Vtable Offset | D3D Method | Usage in Game |
|---|---|---|
| +0x28 | DrawIndexedPrimitiveUP | D3DDevice_DrawIndexedPrimitiveUP (0x47DD56) |
| +0x2C | DrawIndexedPrimitive | Graphics_DrawIndexedPrimitive (0x47DFB9) |
| +0x78 | EndScene | Post-render cleanup |
| +0x88 | BeginScene | Pre-render setup |
| +0x94 | SetTransform | Matrix setup (View/Projection/World/Texture) |
| +0xA8 | SetMaterial | Apply D3DMATERIAL8 |
| +0xF4 | SetTexture | Texture stage 0 setup |
| +0xFC | SetTextureStageState | Multi-texture config |
| +0x118 | SetStreamSource | Vertex buffer binding |
| +0x14C | DrawPrimitiveUP | Immediate-mode vertex draw |
| +0xC8 | SetVertexShader | FVF / declarator setup |
| +0x200 (0x200 = SetRenderState) | SetRenderState | D3D render state changes |
Graphics_DrawIndexedPrimitive (0x47DFB9)
Thin wrapper: device->vtable[0x2C](device, 0, 0, prim_type, index_count | 0x800)
- The 0x800 flag is D3DPT_TRIANGLELIST with additional index buffer flags
- Called from Graphics_ApplyMaterialAndDraw
D3DDevice_DrawIndexedPrimitiveUP (0x47DD56)
User-pointer draw: parses vertex declaration via D3DX_ParseDeclarationType,
then calls device->vtable[0x28]. Falls back to cached declaration if NULL.
Graphics_ApplyMaterialAndDraw (0x455110)
Complex material system dispatch:
- Material selection: gfx+0x7C0 custom material override, or param_1
- Scale transform: If gfx+0x7A8 flag, apply Matrix_ScaleTransform with gfx+0x7B0..0x7BC
- Render state setup based on material type:
- Simple (material[0x12]==0): Set alpha test mode via D3DTS_ALPHA
- Complex: Set texture stage, blend mode, cull mode from material struct
- D3DDevice->SetMaterial (vtable[0xA8]) with material+4
- Lighting state: D3DRS_LIGHTING (0x39) per material flag
- Transform: Level_SetObjectTransform sets world matrix
- Draw: If scaled, call vtable0x4 (render callback)
Material System
Material Structure (0x50 bytes per material)
Materials are stored as arrays of 0x50-byte entries in the mesh vertex data.
Referenced via: object_index * 0x50 + mesh_base_ptr
| Offset | Size | Description |
|---|---|---|
| +0x00 | 4 | Material vtable / type flag |
| +0x04 | 64 | D3DMATERIAL8 structure (SetMaterial payload) |
| +0x12 | 4* | Sub-material pointer (NULL=simple) |
| +0x13 | 1 | Alpha blend mode (0=solid, 1=alpha blend) |
| +0x1C | 1 | Source blend factor |
| +0x1D | 1 | Destination blend factor |
| +0x1E | 1 | Cull mode (0=CCW, 1=CW) |
| +0x4D | 1 | Lighting mode (0=off, 1=on) |
Material Application Flow
Graphics_ApplyMaterialAndDraw(gfx, material_ptr)
├─ Select material (override or default)
├─ Apply scale transform if needed
├─ Set alpha test state (D3DRS_ALPHATESTENABLE via vtable[200](0x1B))
├─ Set texture blend operation (vtable[0xFC])
├─ Set texture (vtable[0xF4])
├─ Set material (vtable[0xA8])
├─ Set lighting (vtable[200](0x39))
├─ Level_SetObjectTransform (world matrix)
└─ Draw (vtable[4])
Graphics Subsystem Functions
| Address | Name | Description |
|---|---|---|
| 0x453B50 | Graphics_BeginFrame | Set view transform + copy to frustum |
| 0x453900 | Graphics_ClearViewport | D3D Clear (target+Z+stencil) |
| 0x453C90 | Graphics_CreateDevice | D3D8 device creation + FPU control |
| 0x454BC0 | Graphics_RenderScene | Full 3D render: lights+matrices+callbacks |
| 0x454AB0 | Graphics_SetProjection | Set projection matrix |
| 0x455110 | Graphics_ApplyMaterialAndDraw | Material + draw dispatch |
| 0x455A90 | Graphics_PresentOrEnd | D3D Present / EndScene |
| 0x455C50 | Graphics_FindOrCreateTexture | Texture cache lookup/create |
| 0x455D60 | Graphics_DrawScreenRect | 2D rectangle (TLVERTEX) |
| 0x457A50 | Graphics_DisableRenderState | Disable a render state |
| 0x57A90 | Gfx_SetRenderStateThunk | Thin SetRenderState wrapper |
| 0x457AB0 | Gfx_LoadMatrixFromStack | Load matrix from stack to D3D |
| 0x457B50 | Gfx_SetPosition | Set world position |
| 0x457B80 | Gfx_SetPositionAndRender | Position + render |
| 0x457BB0 | Gfx_RotateY | Y-axis rotation |
| 0x457C60 | Gfx_ScaleX | X-axis scale |
| 0x457C90 | Gfx_ScaleY | Y-axis scale |
| 0x457CC0 | Gfx_ScaleZ | Z-axis scale |
| 0x457D40 | Gfx_SetAlphaBlendState | Alpha blend state change |
| 0x459400 | Gfx_ResetRenderState | Reset all render states |
| 0x46F100 | Gfx_ApplyLightingState | Apply lighting from scene |
| 0x46F1E0 | Gfx_ResetLighting | Reset D3D lights |
| 0x53960 | Gfx_CallVTable8C | Call device vtable[0x8C] |
| 0x53970 | Graphics_SetCullMode2 | D3DRS_CULLMODE wrapper |
| 0x53940 | Gfx_ResetRenderState | Full state reset |
D3D Texture System Functions
| Address | Name | Description |
|---|---|---|
| 0x472B80 | D3DTexture_Ctor | Texture constructor |
| 0x472C00 | D3DTexture_DeletingDtor | Texture destructor (calls Release) |
| 0x48300C | D3DTexture_Init | Initialize from D3D surface |
| 0x483A44 | D3DTexture_InitLocked | Init with locked rect |
| 0x483BA4 | D3DTexture_CreateFromDesc | Create from texture desc |
| 0x483F2A | D3DTexture_CopyIndexData | Copy index (16-bit) |
| 0x483D6D | D3DTexture_CopyLockedData | Copy locked surface data |
| 0x85525 | D3DTexture_CreateSimple | Create simple texture |
| 0x85610 | D3DTexture_CloneFromDesc16 | Clone from 16-bit desc |
| 0x4C3A | D3DTexture_CopySurfaceData | Copy surface data |
| 0x82F9C | D3DResource_ReleaseA | Release D3D resource |
| 0x82FD4 | D3DResource_ReleaseB | Release variant |
| 0x8A900 | D3DResourcePool_Release | Release from pool |
Mirror Mode
When App+0x7D2 (mirror_cull_flag) is set:
Gfx_SetCullMode(0x427940)changes D3DRS_CULLMODEGfx_SetViewMatrixflips the view matrix horizontally- This effectively mirrors the entire 3D scene
🔗 Related Documents
Reverse Engineering Experiment
Hamsterball — Reverse Engineering Experiments
A collection of reverse-engineering experiments targeting Hamsterball (2004, Raptisoft) —
a 3D marble racing game built on the Athena engine (PE32 i386, DirectX 8 / BASS audio / DirectInput8).
This project explores the game's internal architecture, binary file formats, physics systems,
and modding capabilities through Ghidra decompilation, DLL proxy mods, and custom level creation.
What's in this repo
🗺️ Interactive Knowledge Map — browse all 183 documents as an interactive graph with full-text search (Ctrl+K).
📖 Documentation (81 files across 11 categories)
Comprehensive reverse-engineering documentation generated from Ghidra decompilation of the
original Hamsterball.exe, covering every major game subsystem:
| Category | Docs | Highlights |
|---|---|---|
objects/ |
15 | App, Ball, Scene struct layouts, object factory, global variables |
physics/ |
13 | collision system, ball physics, event planes, raycasting, particle system |
meshworld/ |
7 | Binary file format spec, object types, parser decompilation |
rendering/ |
7 | D3D8 pipeline, camera system, font/text rendering, iteration logs |
gameplay/ |
7 | Arena scoring, rumble board, 8-ball AI, tournament state machine |
decompilation/ |
7 | key function decompilations, full function map, game loop analysis |
modding/ |
6 | DLL modding guides, function reference, custom controls, audio modding |
project/ |
12 | Build notes, file formats, registry system, asset manifest |
ui/ |
3 | Menu system, HUD/timer, text elements |
audio/ |
2 | audio system, SFX reference |
input/ |
2 | DirectInput, full input system with control remapping |
Plus <a href="#22639509422765" title="docs/agent-knowledge" class="record-link ">docs/agent-knowledge</a>/ — a structured onboarding guide for AI agents working on the codebase.
🛠️ DLL Mods (15+ mods)
Runtime modifications via bass.dll proxy injection — no game patches required:
- player_clones — Spawn AI-controlled player balls with custom targeting
- jump_mod — Jump physics with ground detection via raycasting
- 8ball_hit_detect — Detect and log 8-ball collisions
- entity-limit-fixer — Fix game crashes from too many entities
- fps_unlock — Uncap the 30 FPS framerate limit
- half_size_balls — Shrink ball collision radius
- water_mod — Water surface rendering experiment
- collision_hook — Intercept ball-ball collision events
- unlimited_tris — Remove triangle render limit
...and more. See mods/README.md for the full catalog.
🎮 Custom Levels
Custom .MESHWORLD level files created from scratch, verified working in the original game:
DualPlatformArena.MESHWORLD— Two-platform arena layoutDualPlatformArenaV2.MESHWORLD— Refined version with railings
🔧 Tools
tools/mw_create.py— Python MESHWORLD level generatortools/d3d8_proxy_logger/— D3D8 COM proxy logger for API call tracingtools/<a href="#40045643926753" title="recon-analyzer" class="record-link ">recon-analyzer</a>/— Automated binary reconnaissancetools/decompile_batch.py— Batch Ghidra decompilationtools/hbtestd/— Automated game testing harness
📊 Binary Analysis
analysis/— Function catalogs, struct layouts, and JSON exports from Ghidrareference/raptisoft-exporter/— Raptisoft's official MESHWORLD exporter reference
Technical Details
Target binary: Hamsterball.exe (PE32 i386, ~580KB)
Engine: Athena engine — DirectX 8 (D3D8), BASS audio library, DirectInput8
Analysis tools: Ghidra (with custom MCP server for programmatic decompilation),
Cheat Engine for runtime hooks, MinGW for cross-compiling DLL proxies.
Key addresses:
| Symbol | Address |
|---|---|
| App global | 0x005341E0 |
| WinMain | 0x004278E0 |
| Game loop | 0x0046BD80 |
| Ball vtable | 0x004CF3A0 |
| Scene vtable | 0x004D0260 |
Repo Structure
docs/ # 81 RE docs in 11 category subfolders
mods/ # 15+ compiled DLL mods with source
tools/ # Python/C tools for analysis and level creation
analysis/ # JSON catalogs and struct exports from Ghidra
reference/ # Raptisoft official exporter reference
*.MESHWORLD # Custom level files
Credits
RodentRacer — Contributions to the project
Artizard — Contributions to the project
BookwormKevin — Contributions to the project
Makyuni — Contributions to the project
XRow — Contributions to the project
License
Analysis and mod code is original work. Original Hamsterball is copyright Raptisoft.
No original game binaries are distributed in this repo.
🔗 Related Documents
Reverse Engineering Playbook
Reverse Engineering Playbook
Program-agnostic reverse-engineering methodology, with Hamsterball.exe as a running example.
- INDEX.md — start here
- case-studies/hamsterball.md — concrete application of the playbook
scripts/— reusable Python helpers for any targettemplates/— Ghidra Java script templates
🔗 Related Documents
Reverse Engineering Project
Hamsterball Reverse Engineering Project
Project Overview
Goal: Create a documented, legally clean, open-source recreation of Hamsterball (2000s Windows game) that is playable, buildable, and behaviorally faithful to the original.
Permission: Original developer has granted permission for reverse engineering and recreation.
Binary Inventory
| File | Size | MD5 | SHA256 |
|---|---|---|---|
| Hamsterball.exe | 1,404,928 | 7d25019366b8d7f55906325bd630d7fe | 3379e9041c7ab83abd07da1bcf974529... |
| bass.dll | 97,336 | fd5ec122f4dd201b3c3ef19e3058af81 | 11075ca0a1a3064bd971a3faa3856595... |
| unins000.exe | 871,194 | aa765b9cb2afb116f955d063bb7a2b36 | 53da811094504adcfaf6c220bfbc2d65... |
Dependencies Identified
Runtime DLLs (bundled)
bass.dll- BASS audio library
System DLLs (Windows)
- d3d8.dll, d3d9.dll - DirectX 8/9
- ddraw.dll - DirectDraw
- dinput8.dll - DirectInput 8
- dsound.dll - DirectSound
- kernel32.dll, user32.dll, gdi32.dll - Windows core
- advapi32.dll - Windows API
- shell32.dll - Windows Shell
- ws2_32.dll, wsock32.dll - Networking (for eSellerate DRM)
- comctl32.dll - Common Controls
- ole32.dll, oleaut32.dll - OLE Automation
- riched32.dll - Rich Edit
- version.dll - Version Info
External Requirements
- DirectX 8 or higher
- Windows 2000/XP/Vista/7
File Formats
Custom Formats
.MESHWORLD- Level format (custom binary format).MESH- 3D mesh format.MESHCOLLISION- Collision mesh data
Standard Formats
.ogg- Ogg Vorbis audio (sounds).mo3- MO3 audio (music, MOD/tracker format with BASS).png- Textures (with mipmaps).bmp- Textures.xml- Configuration (Jukebox.xml, RaceData.xml).CFG- Save/config (HS.CFG).SAV- Tournament save (TOURNAMENT.SAV)
Asset Inventory
- Levels: 80+ MESHWORLD files (Arena-, Level, Secret-*, etc.)
- Meshes: 40+ custom 3D mesh files
- Textures: 250+ texture files (PNG, BMP with mipmaps)
- Sounds: 80+ OGG sound effects
- Music: 1 MO3 music file
- Fonts: Custom bitmap fonts (ArialNarrow, ShowcardGothic)
Identified Subsystems
- Graphics Engine - DirectX 8/9 based renderer
- Audio Engine - BASS library for music and sound effects
- input system - DirectInput8 for keyboard/gamepad
- Physics - Custom physics for ball movement
- Level System - Custom meshworld format loader
- ui/menu system - In-game menus and UI
- Game Logic - Tournament, race, time trial modes
- Save System - HS.CFG and TOURNAMENT.SAV
- DRM System - eSellerate activation
Development Notes
- Game is PE32 executable for Windows
- Uses C++ with likely custom game engine
- Class structure visible in symbols: App, Graphics, BoardLevel3
- Custom collision detection system
- Supports both single player and 2-player modes
Status
- [x] Original files acquired and hashed
- [x] Asset inventory complete
- [ ] Wine execution environment tested
- [ ] Ghidra analysis started
- [ ] Core subsystems documented
- [ ] Reimplementation started
🔗 Related Documents
Reverse Engineering Skill
Hamsterball Reverse Engineering Skill
Overview
Reverse engineer and recreate the Hamsterball game (2000s Windows game by Raptisoft).
Engine Name Note: The binary only contains one reference to "Athena" — the Win32 window class name "AthenaWindow" at 0x4D9374. There is no "Athena Engine" string, no about box, no internal branding. "Athena" as the engine name is derived from the window class. "Raptisoft" is the company name (Nick Raptis), not the engine name. Use "Athena" as the working name for namespace prefixes (AthenaString, AthenaList, etc.).
How to prove engine names from window class strings: In Win32 games, WNDCLASSEX.lpszClassName is set during window registration and traditionally carries the engine name. Proof from open-source Quake 3 Arena (code/win32/win_glimp.c): #define WINDOW_CLASS_NAME "Quake 3: Arena" followed by wc.lpszClassName = WINDOW_CLASS_NAME; RegisterClass(&wc);. Similarly, Half-Life/Source uses "Valve001", Unity uses "UnityWndClass", GameMaker uses "YYGODragonWindow". These class names are developer-chosen identifiers visible via Spy++ or GetClassName(). In Hamsterball, at 0x46D218 the code does mov [esp+0x30], 0x4D9374 ("AthenaWindow") as lpszClassName before calling RegisterClassExA. This is standard practice — the window class name IS the engine identifier.
Prerequisites
- Ghidra 12.0+ at /opt/ghidra_12.0.4_PUBLIC
- GhidraMCP headless server (v5.2.0) on port 8089 — PRIMARY analysis tool
- radare2 (r2) for quick checks (supplementary, but can be primary if GhidraMCP is down)
- User preference: use ALL available tools in parallel — both Ghidra and r2 simultaneously for this difficult project
- Wine 9.0 for execution testing
- Python 3 with pefile
- CRITICAL: Reimpl must use same APIs as original (D3D8, DirectInput8, BASS) — NOT SDL2+OpenGL. Target i686-w64-mingw32-gcc, link -ld3d8 -ldinput8 -ldsound -ldxguid. Wine handles D3D8→OpenGL for Linux users. See docs/API_STRATEGY.md for details.
Key Locations
- Binary: ~/hamsterball-re/originals/installed/extracted/Hamsterball.exe
- Ghidra project: ~/hamsterball-re/analysis/ghidra/HamsterballProject/
- Docs: ~/hamsterball-re/docs/
- C reimplementation: ~/hamsterball-re/reimpl/
- Level viewer: ~/hamsterball-re/reimpl/build/level_viewer
Critical Function Addresses (CONFIRMED via Ghidra + r2)
| Address | Function | Notes |
|---|---|---|
| 0x4BB4C8 | entry | CRT entry point (GetVersionEx, heap init) |
| 0x4278E0 | WinMain | Calls App_InitFull→App_Run→App_Shutdown |
| 0x429530 | App_Initialize_Full | 26 init steps, debug strings at this+0x208 |
| 0x46BB40 | App_Initialize | 12-step base init (vtable calls + D3D8) |
| 0x46BD80 | App_Run | Game loop: PeekMessage→Update→Render→Present |
| 0x46BA10 | App_Shutdown | Sets this->running=1, vtable jump |
| 0x455380 | Graphics_Initialize | Calls Direct3DCreate8, device creation |
| 0x455A60 | Graphics_Defaults | Set default render states |
| 0x453B50 | Graphics_BeginFrame | Begin frame/render setup |
| 0x455A90 | Graphics_PresentOrEnd | Present frame or end scene |
| 0x45DE30 | LoadMeshWorld | Load .meshworld level file |
| 0x4015b0 | Ball_SetupCollisionRender | Init collision mesh render objects from level data |
| 0x4016f0 | Ball_ApplyForceV2 | Alternate force application (gravity plane, ice/dizzy/tube) |
| 0x402c10 | Ball_RenderWithCollision | Ball render with collision plane check + shadow |
| 0x4280e0 | App_ShowMainMenu | Create MainMenu (0xCDC bytes), store at App+0x224 |
| 0x4288b0 | App_StartTournamentRace | Start tournament: config mirror, create scene, advance race |
| 0x446b80 | RegisterDialog_ValidateSerial | Validate serial via XOR cipher key "54138", registry write |
| 0x459660 | Sound_LoadOggOrWav | Load sound: try .ogg first, then .wav fallback |
| 0x459310 | Sound_LoadOgg | Load OGG Vorbis file into D3D sound buffer |
| 0x459810 | Sound_GetNextChannel | Circular buffer sound channel allocator |
| 0x4706E0 | MeshWorld_ctor | Constructor (0x488 bytes), vtable at 0x4D9CDC |
| 0x470930 | MeshWorld_Parse | Parse text format (*MATERIAL, *MESH tokens) |
| 0x46A020 | LoadMusicFile | BASS_MusicLoad wrapper |
| 0x46A4D0 | LoadJukebox | Parse jukebox.xml |
| 0x42DE50 | MainMenu_ctor | Main menu (LET'S PLAY, HIGH SCORES, OPTIONS, CREDITS, EXIT) |
| 0x442CE0 | OptionsMenu_ctor | Options screen (Resolution, Fullscreen, Color, Volume, Key Remap, Mouse) |
| 0x42B470 | HighScoreEntry_ctor | High score entry screen (name input + score display) |
| 0x42BD40 | HighScoreEntry_Render | Render high score entry UI |
| 0x42E060 | GameSelectionScreen_ctor | Tournament difficulty selector screen |
| 0x44FD60 | SaveTourneyDialog_ctor | Save tournament dialog |
| 0x4476B0 | RegisterDialog_ctor | Register/purchase dialog |
| 0x4652E0 | CollisionLevel_ctor | Collision-only level (.meshcollision format) |
| 0x465260 | Level_LoadCollision | Load binary collision mesh (planes, objects, AABB) |
| 0x4624C0 | Level_Cleanup | Level destructor (free objects, VBs, textures) |
| 0x413C20 | ArenaLevel_WarmUp_Init | Initialize Warm-Up arena (levels\arena-WarmUp) |
| 0x416F40 | ArenaLevel_Neon_Init | Initialize Neon arena (levels\arena-neon) |
| 0x456E20 | Font_MeasureText | Measure text string width for centering |
| 0x457440 | Font_DrawGlyph | Core glyph rendering (1 call = 1 glyph quad) |
| 0x4013A0 | UI_DrawTextCenteredAbsolute | Draw centered text (x - width/2) |
| 0x409C60 | UI_DrawTextCentered | Draw centered text with shadow |
| 0x4012C0 | UI_DrawTextShadow | Draw text with shadow (offset + main) |
| 0x40A120 | LoadRaceData | Parse racedata.xml |
| 0x413280 | CameraLookAt | Camera look-at targets (CAMERALOOKAT) |
| 0x429200 | ESellerate_Init | eSellerate DRM init |
| 0x4254E0 | CreditsScreen_ctor | Credits scrolling screen (formerly mislabeled Physics_Init) |
| 0x469CF0 | GameUpdate | Main game tick - iterates objects, calls vtable+4 (Update) and vtable+0x3C (Render) |
| 0x428160 | PauseGame | Pause game (RightButtonPause) |
| 0x42FAD0 | QuitRace | Quit current race |
| 0x4298C0 | TimerDisplay | Race timer display (timerblot.png) |
| 0x40FA20 | CreateBumper | BUMPER1/2/3/4 objects |
| 0x40E250 | CreateExpertLevelObjects | SAWBLADE objects |
| 0x4117B0 | CreateUpLevelObjects | SPEEDCYLINDER |
| 0x412850 | HandleArenaCollisionEvents | N:SPINNER |
| 0x410D00 | NeonCollisionEvents | Neon board collision handler (E:PEGS, E:HEATON, E:LIMIT, etc.) |
| 0x40BF50 | CreateMouseTrap | MOUSETRAP |
| 0x40C5D0 | DispatchCollisionEvents | Main collision event dispatcher — handles ALL object types: E:NODIZZY, E:SAFESWITCH, E:LIMIT, E:BREAK, E:JUMP, E:ACTION (ONCE/SCORE), E:TRAJECTORY, N:NOCONTROL, N:WATER, N:TARPIT, N:GOAL, N:MOUSETRAP, N:SECRET, N:UNLOCKSECRET, DROPIN, PIPEBONK, POPOUT |
| 0x40E6A0 | ExpertCollisionEvents | Expert board events: E:CALLHAMMER, E:HAMMERCHASE, E:ALERTSAW1/2, E:ACTIVATESAW1/2, E:ALERTJUDGES, E:SCORE, E:JUMP, E:BELL (+delegates to DispatchCollisionEvents) |
| 0x40DCD0 | TowerCollisionEvents | Tower board events: E:CATAPULTBOTTOM, E:OPENSESAME, N:TRAPDOOR, E:BITE, E:MACETRIGGER, N:MACE (+delegates to DispatchCollisionEvents) |
| 0x434770 | Saw_AlertActivate | Saw blade alert mode (clear flag + 3D sound) |
| 0x434A50 | Saw_Activate | Saw blade full activate (set flag + 3D sound) |
| 0x434C40 | Judge_Reset | Reset judge objects |
| 0x434C80 | ScoreDisplay_SetTime | Set score display time value |
| 0x434E20 | Bell_Activate | Activate bell (+5 seconds extra time) |
| 0x438BB0 | Hammer_ChaseStart | Start hammer chase sequence |
| 0x434290 | Catapult_Launch | Catapult launch (set active + timer) |
| 0x4344D0 | Trapdoor_Open | Open trapdoor (scale 0→1.0) |
| 0x438410 | Trapdoor_Activate | Activate trapdoor (3D sound + timer) |
| 0x459860 | Sound_Play3D | Play 3D positioned sound (set BASS pos + play) |
| 0x4597B0 | Sound_PlayChannel | Play sound channel (pool dispatch) |
| 0x4595B0 | Sound_StartSample | Start BASS sample (vtable: reset, volume, 3D pos) |
| 0x46EC30 | Ball_GetInputForce | 3 |
| 0x466750 | Sound_CalculateDistanceAttenuation | 3 |
| 0x44C260 | RaceResultPopup_ctor | Race end popup (rank image + TIME'S UP!/OUT OF TIME!) |
| 0x438B30 | CreateBonkPopup | BONKPOPUP feedback |
| 0x40BAA0 | CreateSecretObjects | Create SECRET and SECRETUNLOCK objects |
| 0x43DFB0 | Secret_ctor | Secret object constructor (0x10EC bytes) |
| 0x4121D0 | CreateLevelObjects | Factory: BRIDGE, TIPPER, BONK, BBRIDGE1/2, POPCYLINDER, BLOCKDAWG1/2, CATAPULT, GLUEBIE |
| 0x4133E0 | CreatePlatformOrStands | Factory: PLATFORM, STANDS |
| 0x417FE0 | CreateMechanicalObjects | Factory: LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM |
| 0x37040 | Platform_ctor | Platform constructor (0x10FC bytes) |
| 0x462850 | Stands_ctor | Stands constructor (0x10D0 bytes) |
| 0x435800 | Looper_ctor | Looper constructor (0x1500 bytes) |
| 0x437590 | Gear_ctor | Gear/BigGear constructor (0x1514 bytes) |
| 0x435940 | Rotator_ctor | Rotator constructor (0x1508 bytes) |
| 0x437700 | Pendulum_ctor | Pendulum constructor (0x1504 bytes) |
| 0x437960 | Tipper_ctor | Tipper constructor (0x1104 bytes) |
| 0x4661A0 | TipperVisual_ctor | Tipper visual component |
| 0x465200 | TipperVisual_Attach | Attach visual to tipper |
| 0x438850 | Bonk_ctor | Bonk (hammer) constructor (0x1200 bytes) |
| 0x436D70 | BreakBridge_ctor | Breakable bridge constructor (0x1100 bytes) |
| 0x436EE0 | PopCylinder_ctor | Pop cylinder constructor (0x10E8 bytes) |
| 0x43C310 | Blockdawg_ctor | Blockdawg constructor (0x1154 bytes) |
| 0x437E10 | Catapult_ctor | Catapult constructor (0x1108 bytes) |
| 0x437CB0 | Gluebie_ctor | Gluebie (glue blob) constructor (0x110C bytes) |
| 0x41D060 | LevelBoard_Dizzy_ctor | BoardLevel3 constructor, vtable at 0x4D0890 |
| 0x40ABA0 | CheckArenaUnlock | Check arena unlock conditions |
| 0x4279F0 | LoadOrSaveConfig | Config load/save dispatcher |
| 0x42AE80 | LoadConfig | Load HS.CFG |
| 0x42B6E0 | SaveConfig | Save HS.CFG |
| 0x433AC0 | GameSelectionManager | Tournament save/load |
| 0x457130 | LoadFont | Load font.description + PNG glyphs |
| 0x429450 | FinishLoad | Final setup after loading |
| 0x46EE10 | Input_Init | DirectInput8 init (0x438 byte object), stored at App+0x180 |
| 0x46C110 | Input_Create | Creates Input object, stores at param+0x180 |
| 0x46F010 | Input_Cleanup | Input object cleanup/release |
| 0x46F0E0 | Input_dtor | Input destructor (delegates to Input_Cleanup) |
| 0x46E250 | KeyboardDevice_ctor | DI keyboard device (0x524 bytes), vtable at 0x4D9840 |
| 0x46DE60 | KeyboardDevice_dtor | Keyboard cleanup, releases DI device, unbinds keys |
| 0x461510 | Level_ctor | Level object constructor (loads MeshWorld, creates LevelState) |
| 0x461460 | LevelState_ctor | Ball/physics state object (0x10D4 bytes) |
| 0x4629C0 | Level_dtor | Level destructor |
| 0x65080 | Level_Clone | Clone level object for secondary display |
| 0x46F310 | Level_base_ctor | Base level init (before vtable set) |
| 0x19030 | Board_ctor | Base Board/Screen class (vtable at 0x4D0260) |
| 0x453210 | AthenaList_Init | Init 256-entry hash list (used everywhere) |
| 0x453280 | AthenaList_Clear | Clear list contents |
| 0x4532B0 | AthenaList_GetIndex | Get list iteration index |
| 0x4534D0 | AthenaList_Remove | Remove item from list |
| 0x453780 | AthenaList_Append | Append item to dynamic list (malloc/realloc) |
| 0x4532E0 | AthenaList_SortedInsert | Insert with insertion-sort (ascending/descending) |
| 0x402BC0 | AthenaList_SetIndex | Set list iteration index |
| 0x40A020 | AthenaList_GetAt | Get element by index with bounds check; returns 0 if OOB |
Sound System — Extended (Session 2026-04-13)
| Address | Function | Notes |
|---|---|---|
| 0x458f40 | SoundDevice_LoadWAV | Load WAV file→DirectSound buffer. Validates RIFF/WAVE header, "data" chunk, 6 error paths |
| 0x4668a0 | SoundDevice_dtor | Release all DS buffers, save volume to registry, free memory |
| 0x466570 | SoundDevice_ReadVolume | Read Sound Volume float from registry (default 1.0f) |
Race Timer System (NEW — Session 2026-04-13)
| Address | Function | Notes |
|---|---|---|
| 0x451df0 | RaceTimer_Tick | Advance frame, adjust speed, accumulate score, determine rank (0-15), load rank sprite |
| 0x44c880 | RaceTimer_ctor | Init timer, score thresholds (100-900), load weasel.png, update high score |
Registry/GameInfo Utilities (Session 2026-04-13)
| Address | Function | Notes |
|---|---|---|
| 0x473000 | RegKey_WriteDword | Write REG_DWORD (type 3) to registry key |
| 0x475430 | GameInfo_AddSoundEntry | Add "SOUND" resource entry (0x48 bytes) |
| 0x4752f0 | GameInfo_AddSpriteEntry | Add "SPRITE" resource entry (0x48 bytes) |
| 0x475270 | GameInfo_AddCMeshEntry | Add "BCMESH" collision mesh entry (0x48 bytes) |
| 0x46d91d | GameInfo_WriteDSoundTags | Write DSOUND/CURRENTOBJECT/CURRENTOPERATION tags |
| 0x46d230 | GameInfo_BuildFullReport | Build full diagnostic report (PRODUCT, VERSION, D3D8/9, DSOUND, etc.) |
| 0x4764f0 | D3DXERR_ToString | Convert D3DX error code to human-readable string |
Graphics/UI — Extended (Session 2026-04-13)
| Address | Function | Notes |
|---|---|---|
| 0x453ed0 | Graphics_ReadQualitySettings | Read Texture Quality, ColorMode, SafeMode from registry |
| 0x453f90 | Graphics_WriteQualitySettings | Write Texture Quality, ColorMode, SafeMode to registry |
| 0x4794d0 | SplashScreen_ctor | Brand logo + raptisoftlogo.png + showcardgothic16 font |
| 0x487070 | FontFormatString_WriteFloat | printf-style float formatting (%f, %g, %e, %a) |
| 0x4819ec | D3DXMesh_AttributeSort | Sort mesh faces by attribute ID, rebuild attribute table |
| 0x444880 | AbortConfirm_Render | "REALLY ABORT?" dialog with YES/NO, score reset warning |
| 0x445410 | RollbackConfirm_Render | "REALLY ROLL BACK?" dialog, score zeroing warning |
| 0x4431e0 | KeyRemapMenu_ctor | Keyboard remap menu (Up/Down/Left/Right/Action1/Action2) |
| 0x474900 | LoaderGadget_OK | LoaderGadget OK handler, set operation name |
Math Utilities (NEW — Session 2026-04-13)
| Address | Function | Notes |
|---|---|---|
| 0x401AA0 | Vec3_NormalizeAndScale | 59 xrefs — most common math utility. Normalizes and scales to length param_1. |
| 0x401D60 | Matrix_TransformVec3 | 15 xrefs. Transform 3D vector by 4x3 matrix. |
| 0x402BF0 | Vec3_Copy | 18 xrefs. Copy 3 floats with self-check. |
| 0x40A050 | Color_RandomRGBA | Generate 32-bit color from 4 random bytes. |
| 0x4580D0 | AABB_ContainsPoint | Test if (x,y,z) inside AABB. Used by collision spatial tree. |
| 0x453150 | Matrix_Scale4x4 | Set 4x4 matrix row scale values. |
| 0x453200 | Matrix_Identity | Set matrix to identity. |
| 0x458B50 | Matrix_ScaleTransform | Create 4x4 by scaling source matrix rows. |
Rendering Pipeline — Extended (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x4542C0 | Graphics_ctor | Constructor (vtable 0x4D88A0, init render context, texture cache, frustum) |
| 0x455360 | Graphics_dtor | Destructor (cleanup + optional free) |
| 0x454550 | Graphics_Cleanup | Release D3D objects, free texture path, clear cache |
| 0x454000 | Graphics_SetTexturePath | Set custom texture prefix (strdup at +0x7D8) |
| 0x454060 | D3DFMT_ToString | Convert D3DFORMAT enum to debug string |
| 0x454B50 | Graphics_SetViewport | Set viewport dimensions |
| 0x454D30 | Graphics_Reset | Reset device with new params (CreateDevice twice) |
| 0x455D60 | Graphics_DrawScreenRect | Draw 2D rectangle via TLVERTEX tristrip. 63 xrefs. |
| 0x455110 | Graphics_ApplyMaterialAndDraw | Apply material states + draw. 17 xrefs. |
| 0x454190 | Graphics_SetRenderMode | Set shading mode, vertex shader, render states |
| 0x455B80 | Graphics_SetStreamBuffers | Set vertex buffer streams |
| 0x457FA0 | RenderContext_Init | Init 0x50 byte render context (vtable 0x4D8E68) |
| 0x401160 | Graphics_SetViewportClip | Set viewport clip from 4x4 matrix |
Ball Physics — Extended (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x403100 | Ball_SetTiltedGravity | Gravity plane=1, normal (-1,0,0) |
| 0x403150 | Ball_SetFlatGravity | Gravity plane=2, normal (0,0,1) |
| 0x403850 | Ball_SetTrajectory | Set trajectory direction + force scale |
| 0x403750 | Ball_ApplyTrajectory | Apply trajectory force (normalize, scale, sound, counter=100) |
| 0x403980 | Ball_FindMeshCollision | Mesh_FindClosestCollision wrapper |
| 0x401DD0 | Ball_CreateTrailParticles | Create trail particles (10 iterations, 0x28 byte objects) |
| 0x401920 | Ball_RenderShadow | Render ball shadow at XYZ |
| 0x401DD0 | Ball_CreateTrailParticles | Create trail particles: 9 iterations, spherical camera-relative distribution using ArenaScoreParticle objects, velocity=RNG/(RNG+20), appended to scene+0x3b00 |
| 0x413280 | CameraLookAt | Arena camera init: Load arena-spawnplatform + arena-stands meshes, find CAMERALOOKAT target, set distance=45f, height=800f, max_height=800f |
| 0x419FA0 | Scene_SetCamera | 5 camera modes: Default follow, Path rail (spring+sin dampening, dist<700→0, >700→offset-sin), Shake (±50 random offset), Snap (countdown frames→hard position), Orbit (sin/cos rotation at +0x29BC angle, dist +0x29C0). Ball target at +0x758, actual at +0x76C |
| 0x4284C0 | App_SaveAllConfig | Save all config to registry: MouseSensitivity, MirrorTournament, 11 race unlock flags, 9 arena unlock flags, RightButtonPause, BestTime[0x50 bytes], Medals[0x50 bytes], 2PController1-4. Registry key at App+0x54 |
| 0x4279F0 | LoadOrSaveConfig | Config destructor: delete all resources (graphics, meshes, sounds, UI elements), save config, call ShellExecuteA("http://www.raptisoft.com") if !registered, call App_Shutdown |
| 0x446730 | Tourney_SaveTournament | Save tournament: remove DATA\tournament.sav, call vtable save function |
| 0x446730 | Tourney_SaveTournament | Save tournament: remove DATA\tournament.sav, call vtable save function |
Collision System — Extended (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x465EF0 | Collision_TraverseSpatialTree | Recursive octree traversal calling AABB test for each face |
| 0x458000 | Collision_InitDefaultAABB | Set AABB bounds to ±39M (0x4b18967f) |
| 0x4583F0 | AABB_TriangleIntersect2 | Double AABB-triangle test (calls AABB_TriangleTest6Edges twice) |
| 0x458190 | Collision_GradientEval_Stub | Empty stub — called from Ball_Update and Gear_AdvanceAlongPath |
| 0x456D80 | CollisionMesh_ctor | CollisionMesh constructor: 0xCB4 bytes, vtable 0x4D8E10. Initializes triangle list (AthenaList at +0x18), material list (AthenaList at +0x430), vertex list (AthenaList at +0x848), position at +0xCA4, scale factors at +0xC64/0xC68 |
| 0x456120 | CollisionMesh_AddTriangle | Add triangle ref to collision mesh: appends to list at +0x430, sets back-pointer at +0x08 |
| 0x4564C0 | Ball_AdvancePositionOrCollision | CORE PHYSICS FUNCTION: Advances ball position with collision detection. Uses SpatialTree for mesh traversal, accumulates velocity (gravity + scale), handles sub-step interpolation with position += direction * (1 - step), collision response via vtable+0x1C callback, material tracking. Offsets: +0x14=hasTrailData, +0x18=trail data, +0x430=triangles, +0x848=vertex normals, +0xC64=scale, +0xC68=friction, +0xC70=max_distance, +0xC74=accumulated_distance, +0xC90-C98=gravity, +0xCA4=position(XYZ), +0xC7C=useGravityCallback flag |
| 0x463330 | SpatialTree_ctor | SpatialTree (octree) constructor for collision spatial partitioning. vtable 0x4D9038. Init values: +0x0C=0.1f (scale), +0x10=6 (max_depth), +0x14=0.9f (0x3F666666, min_extent). Flags +0x18-0x1E=1 (enable all axes) |
| 0x4632E0 | SpatialTree_Free | Free spatial tree leaf nodes + cleanup |
Board Level Constructors (Session 2026-04-13 Batch 5)
Pattern: Each Board constructor calls Board_ctor, sets vtable, name strings ("Board (Name)"), level unlock flag, Vec3_Init for background color, Matrix_Identity, LoadRaceData, then creates Level_ctor + Level_Clone for split-screen, and sometimes MeshNode_ctor for static geometry.
| Map Case | Level | Board Address | Levels Loaded |
|---|---|---|---|
| 1 | Warm-Up | 0x41CA40 | none (uses existing data) |
| 2 | Intermediate | 0x41CB20 | Level2-Bridge (Intermediate) |
| 3 | Dizzy | 0x41D060 (known) | Level3-Swirl |
| 5 | Tower | 0x41E340 | Level4-Catapult, Level4-Drawbridge, Level4-Mace, Level4-Windmill, Level4-Turret + YellowLink, Chomper meshes |
| 8 | Expert | 0x41EA40 | Level5-Bridge + 3x hammyjudge meshes |
| 9 | Odd | 0x41ED80 | none (single level) |
| 12 | Wobbly | 0x41F110 | Level7-Wobbly1 through Level7-Wobbly7 (7 levels!) |
Input System (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x46E0B0 | Input_IsKeyDown | Multi-device key check (keyboard=0, mouse=1, joystick=3-7) |
| 0x46EC30 | Ball_GetInputForce | Get ball input direction from keyboard/mouse/joystick |
| 0x46EE10 | Input_Init | DirectInput8 init |
| 0x46C110 | Input_Create | Creates Input object, stores at App+0x180 |
| 0x46F010 | Input_Cleanup | Input object cleanup/release |
Mesh System (Extended)
| Address | Function | Notes |
|---|---|---|
| 0x456390 | Mesh_Clear | Free vertex/index buffers, clear AthenaLists |
| 0x46F340 | MeshBuffer_Cleanup | Cleanup mesh buffer resources, vtable 0x4D9C48 |
| 0x46F670 | Mesh_SaveAndFree | Save mesh data to file (magic 0xBEEF), free buffers |
| 0x46FD60 | Mesh_AddVertex | Add vertex (8 floats: pos+normal+uv), dedup, error "Too many vertices" |
| 0x46C970 | Texture_SetDimensions | Set texture size, compute UV scale factors |
Game Object Factories (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x418760 | Scene_CreateObject_Gear | Create GEAR/BigGear object (0x1514 bytes) |
| 0x418930 | Gear_AdvanceAlongPath | 8-direction gradient descent path following |
Game Object Factories — Extended (Session 17)
| Address | Function | Notes |
|---|---|---|
| 0x4143d0 | CreateSpinny | Factory: matches "SPINNY" → Rotator_ctor (0x1508 bytes) |
| 0x414a20 | CreateLifter | Factory: matches "LIFTER" → Lifter_ctor (0x436920, 0x10f4 bytes), falls through to CreatePlatformOrStands |
| 0x415460 | CreateWobbly1 | Factory: matches "WOBBLY1" → GameLevel_ctor (0x1524 bytes), falls through to CreatePlatformOrStands |
| 0x436920 | Lifter_ctor | Lifter object constructor (0x10f4 bytes) |
| 0x413fc0 | ArenaBoard_RenderMultiView | Render 4 dynamic objects with zoom step adjustment |
| 0x4151e0 | ArenaBoard_RenderMultiView5 | Render 5 dynamic objects with zoom step adjustment |
| 0x4155d0 | ArenaBoard_InitScene4 | Init scene objects for 4-player split, assign materials + Level_SetObjectTransform |
| 0x415d90 | ArenaBoard_InitScene5 | Init scene objects for 5-player split, assign materials + Level_SetObjectTransform |
String/Utility Functions (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x463790 | Vec3_CrossProduct | Cross product: this × param2 → param1 |
| 0x467750 | Array_CopyDWords | Copy N dwords ptr-to-ptr |
| 0x470650 | Array_FillDWords | Fill N dwords with same value |
| 0x472DA0 | Transform_ctor | Transform object constructor |
| 0x4730C0 | Registry_ReadFloat | Read float value from Windows registry |
| 0x473740 | StdString_AppendCharN | Append N copies of a char |
| 0x4738B0 | StdString_AppendN | Append N chars with strncat |
| 0x473B10 | AthenaString_AssignFormatted | Format and assign AthenaString |
Rendering Pipeline — Extended (Session 16)
| Address | Function | Notes |
|---|---|---|
| 0x456150 | Ray_SetDirection | Set ray direction, normalize, compute length |
| 0x456E80 | Font_WordWrap | Word-wrap text to fit pixel width |
| 0x459610 | Scene_RenderIfVisible | Render scene object only if visible flag set |
| 0x466AC0 | Scene_UpdateChildren | Traverse scene tree, update children |
| 0x4602F0 | Scene_CollectByNameFilter | Collect scene objects by name string filter |
| 0x440DD0 | Graphics_DrawRectAndReset | Draw rect then reset matrix to identity |
Scene/Board System (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x429520 | Game_SetInProgress | Set game in-progress flag (+0x200=1) |
| 0x44BE80 | ScoreObject_ctor | Score display constructor (vtable 0x4D6C70, score=200000) |
| 0x446B80 | RegisterDialog_ValidateSerial | Validate serial via XOR cipher key "54138" |
Level Rendering — Extended (NEW)
| Address | Function | Notes |
|---|---|---|
| 0x40B090 | Level_InitScene | Init scene (projection, fog, find CAMERALOCUS) |
| 0x40B420 | Level_RenderDynamicObjects | Render moving objects via Timer positions |
| 0x40B570 | Level_RenderObjects | Iterate objects calling vtable+0x0C |
| 0x40B600 | Level_UpdateAndRender | Full update: merge lists, pre-render, shadow, cleanup |
| 0x40B9C0 | Level_SetObjectTransform | Set world transform from position |
| 0x40ACA0 | Level_SelectCameraProfile | Select camera by difficulty (4-15) |
App Object Structure (partial, at 0x4FD680)
+0x00: vtable pointer
+0x04: hInstance (Windows HINSTANCE)
+0x08: cmdShow (nCmdShow)
+0x54: registryKey (Config* pointer)
+0x5A: frameTimeMs (frame delta)
+0x5B: fpsDenominator
+0x5C: targetFPS
+0x5D: renderTarget (render target ptr)
+0x65: frameCounter (per-second count)
+0x84: profilingSection (char* current section name)
+0x15A: someFlag
+0x15C: width (window width)
+0x158: windowed (windowed mode flag)
+0x159: quitFlag (1 = game should quit)
+0x160: height (window height)
+0x174: Graphics* pointer (D3D device wrapper)
+0x17C: audioSystem pointer (BASS audio)
+0x180: Input* pointer (DirectInput8 subsystem, 0x438 bytes)
+0x1B4: versionString
+0x1CC: loadedCount (objects counter)
+0x200: initialized flag
+0x208: char[256] debug_string (current init step name)
+0x240: HCURSOR blank_cursor
+0x278: void* shadow_texture
+0x534: MusicPlayer* music handle
+0x538: int music_channel2
+0x53C: int music_channel1
+0x550: gameMode1 (mode value 1)
+0x554: gameMode2 (mode value 2)
+0x558: gameMode3 (mode value 4)
+0x55C: gameMode4 (mode value 5)
+0x914: int play_count (shareware trial)
App::Initialize Sequence (0x429530) — 26 Steps
Steps 1-12 are in App_Initialize (0x46BB40):
- vtable+0x94: Parse command line
- Registry/config init (0x472F50)
- vtable+0x0C: Window creation (AthenaWindow class)
- vtable+0x1C: Subsystem init
- vtable+0x18: Subsystem init
- vtable+0x30: Subsystem init
- vtable+0x3C: Subsystem init
- vtable+0x34: Subsystem init
- vtable+0x38: Subsystem init
- vtable+0x40: Subsystem init
- Graphics_Initialize (Direct3DCreate8 + device creation)
- Version string extraction
Steps 13-26 are in App_Initialize_Full (0x429530):
13. Set graphics->initialized = true
14. Load "BLANKCURSOR"
15. Set display mode 800x600
16. Configure D3D render states
17. N/A (skipped)
18. Load "shadow.png" texture
19. Load "music\music.mo3"
20. Load "jukebox.xml"
21. Set up music channel 1
22. Set up music channel 2
23. Config::Load + read "PlayCount"
24. Set initialized flag
25-28. Create 4 game mode objects (values 1,2,4,5)
29. Config save
30. vtable+0xA0: final init callback
Session Progress (From Context Compaction)
| 0x478680 | Texture_dtor | 4 | Destroy texture: free sub-textures, pixel buffers, palette, alpha, vtable release |
| 0x46DB10 | App_Shutdown | 4 | Application cleanup: destroy window, release 5 COM objects, CoUninitialize, free string buffer |
| 0x473640 | AthenaString_Find | 4 | strstr wrapper: returns offset of substring or -1 |
| 0x473600 | AthenaString_Length | 4 | Recalculate and cache string length, clear dirty flag at +0x10 |
| 0x469B20 | UIWidget_HitTest | 3 | Hit-test UI widget list against (x,y) point |
| 0x477670 | Vec3_ClosestPointOnLine | 3 | Project point onto line segment, clamp t to [0,lineLength] |
| 0x475DC0 | CRC32_Compute | 3 | CRC32 using 256-entry lookup table at DAT_004F7534 |
| 0x472990 | Gadget_LabelCtor | 8 | Gadget_Label constructor: vtable 0x4D9E68, name at +0x878, value at +0x87C |
| 0xA00000 | Key Vtable Addresses | | |
| 0x4DA65C | D3DX_RegistryGetter_vtable | | DirectX registry path struct vtable |
| 0x4E998C | AthenaList_ctor_vtable | | AthenaList constructor vtable |
| 0x4D9750 | App_Shutdown_vtable | | App shutdown vtable |
Ball Physics Constants (CONFIRMED from Ghidra decompilation)
- Ball radius = 35.0 at offset +0x284
- Position: +0x164/168/16C (XYZ), Velocity: +0x170/174/178 (XYZ)
- First-frame force modifier at +0x2F0 (1.0→0.25), Ice at +0xC5C, is_shrunk at +0xC4C (odd race E:SHRINK/E:GROW only, NOT falling/dizzy)
- Gravity plane at +0x748 (0=XY, 1=tilted, 2=XZ)
- Ball vtable entry +0x04 is dispatcher at 0x405100 that sets defaults: +0x278=0.5, +0x27C=0.2, +0x188=6.0, +0xC6C=600.0, +0xC70=1200.0
Level::BinaryLoader (0x4629E0) — Confirmed Binary MESHWORLD Format
Opens file with _open(path, 0x8000), reads: material_count → materials (with extended/texture data) → mesh_buffers → game_objects → bounding_box → vertex_array. See SKILL.md for full format spec.
MESH Parser Status
- v1 (Sphere, 8Ball, Hamster, FunBall): FULLY WORKING
- v2-v5: Partial (finds texture, may miss sub-object vertices)
OpenGL Level Viewer v2
- Renders object markers from MESHWORLD levels in 3D fly-through
- 86 levels load OK, Level1 has 140+ objects
- Build: gcc -o build/level_viewer_v2 src/level/mesh_parser.c src/level/level_viewer_v2.c -I include -lm (pkg-config --libs sdl2 SDL2_image gl glew) -lGLU
TODO Next Steps
- Parse MESHWORLD vertex/face geometry for real mesh rendering
- MESH v2-v5 sub-object support
- Collision plane system RE (Ball_CheckCollisionPlanes 0x402810) — partially done: spatial tree + AABB test documented
- Connect GhidraMCP for interactive decompilation — DONE, working
- rendering pipeline RE — substantially advanced (ctor, dtor, DrawScreenRect, ApplyMaterialAndDraw, SetRenderMode documented)
- Ball physics gravity + trajectory system documented (SetTiltedGravity, SetFlatGravity, SetTrajectory, ApplyTrajectory)
Scene Vtable (0x4D0260) — 36 Entries (144 bytes)
| Index | Address | Function | Description |
|---|---|---|---|
| 0 | 0x419770 | Scene_DeletingDtor | Destructor (scalar deleting) |
| 1 | 0x419C00 | Scene_Update | Main update tick |
| 2 | 0x41A2E0 | Scene_Render | Main render tick |
| 3 | 0x41C620 | Scene_HandleInput | Input dispatch (menu navigation, ball control) |
| 4 | 0x41C7D0 | Scene_ActivateCurrentItem | Activate selected menu item |
| 6 | 0x41CA00 | Scene_SelectCurrentItem | Select/highlight menu item |
| 7 | 0x409D90 | (3-byte nop stub) | Unused vtable slot |
| 11 | 0x41CB60 | Scene_ClearCurrentItem[1] | Clear item selection variant 1 |
| 12 | 0x41CBF0 | Scene_ClearCurrentItem[2] | Clear item selection variant 2 |
| 14 | 0x419900 | Scene_DestroyScene | Destroy scene + cleanup |
| 15 | 0x419A40 | Scene_NotifyObjects | Notify all scene objects of event |
| 16 | 0x419970 | Scene_SetDestroyed | Set destroyed flag (+0x2C) |
| 17 | 0x4198E0 | Scene_SaveAndCleanup | Save state + cleanup |
| 19 | 0x419E20 | Scene_HandleRaceEnd | Handle race completion |
| 20 | 0x41A180 | Scene_UpdateBallsAndState | Update ball physics + race state |
| 22 | 0x41A050 | Scene_ProcessRaceEnd | 3-2-1-GO countdown logic |
| 23 | 0x419E80 | Scene_HandleBallFinish | Ball finish state machine (5 states) |
| 27 | 0x41A560 | Scene_RenderScoreHUD | Render score HUD overlay |
| 28 | 0x41A680 | Scene_RenderTimerHUD | Render timer HUD overlay |
| 32 | 0x41C5B0 | Scene_SpawnBallsAndObjects | Create game objects (balls, traps, secrets, flags) |
| 35 | 0x41A9A0 | Scene_ComputeInputForceDirection | Computes 3D force vector from strongest player input across balls in slot (Ghidra label "Scene_ComputeLighting" is a MISNOMER) |
Scene_Update (0x419C00) — Main Game Tick (10 phases)
| Phase | Description | Key Offsets |
|---|---|---|
| 1 | Frame counter++ | this+0xD88 (= 0x3620) |
| 2 | Demo timer countdown | this+0x10D6 (active), +0x10D7 (frames left), +0x10D8 (elapsed), +0x10D9 (counter) |
| 3 | ESC check → Scene_CreateGameOverMenu | this+0x10DA (ignore ESC), App+0x5FC (input mode) |
| 4 | Ball position propagation | this+0xA6C (flag), iterate +0xA75 ball list |
| 5 | Gear path follow (single player) | this+0xFC7 (gear enabled), +0xFC8 (path data) |
| 6 | ArenaBoard timer ticks | this+0x221, this+0x226 (two ArenaBoard timers) |
| 7 | Camera shake decay | this+0xE93 (shake active), +0xA6E (magnitude, ±800, decay -10/frame) |
| 8 | scene object update+render | iterate this+0x22E, vtable[4]=Update, vtable[0]=Render |
| 9 | Per-frame update pipeline | vtable[0x4C]=Scene_HandleRaceEnd, [0x50]=Scene_UpdateBallsAndState (ball update+respawn), [0x54]=NoOp, [0x58]=Scene_ProcessRaceEnd + physics objects at this+0xD8B |
| 10 | Post-physics callback | this+0xEBF+0x04() |
Scene_HandleBallFinish State Machine (5 states)
state 0: start → set counter=150.0f, advance to state 1
state 1: countdown → decrement counter by dt, when ≤0 advance to state 2
state 2: finish → save race time, play "Goal!" music, advance to state 3
state 3: popup → show finish popup, advance to state 4
state 4: done → wait for popup dismissal
Scene Key Offsets
- +0x2C: destroyed flag (set by Scene_SetDestroyed)
- +0x864: current item pointer (menu selection)
- App+0x10EC: race active flag (1=active, set by Scene_SetRaceActive at 0x4366E0, 62 xrefs)
SceneObject Vtable (0x4D934C) — 10 Entries
| Index | Address | Function | Description |
|---|---|---|---|
| 1 | 0x46B560 | SceneObject_SetPosition | Set world position (XYZ) |
| 2 | 0x46B5A0 | SceneObject_SetScale | Set scale vector |
| 3 | 0x46B4B0 | SceneObject_Render | Render scene object |
| 4 | 0x46B4D0 | SceneObject_SetVisible | Toggle visibility flag |
| 7 | 0x46B860 | SceneObject_BaseDtor | Base dtor: iterates child list, calls each child's dtor(1), clears list |
| 8 | 0x46B650 | SceneObject_DeletingDtor | Scalar deleting destructor |
Tournament & Level System (NEW — Session 2026-04-13)
Tournament_AdvanceRace (0x427080) — CORRECTED from decompilation
15-case switch creates specific Board constructors. Key discovery: CreateExpertLevelObjects (0x40E250) is the MASTER ARENA OBJECT FACTORY — creates ALL arena hazard types (Sawblade, TowerLevel, Spinner, Gear/Judge, Tipper/Bell, Bonk/Hammer) by name prefix matching.
| Case | Level | Board Constructor | Size |
|---|---|---|---|
| 1 | Warm-Up | LevelBoard_WarmUp_ctor | 0x436C |
| 2 | Beginner | LevelBoard_Beginner_ctor | 0x644C |
| 3 | Intermediate | LevelBoard_Intermediate_ctor | 0x438C |
| 4 | Dizzy | LevelBoard_Dizzy_ctor | 0x4BE0 |
| 5 | Tower | LevelBoard_Tower_ctor | 0x5418 |
| 6 | Up | LevelBoard_Up_ctor | 0x4790 |
| 7 | Neon Race | Board_NeonRace_ctor | 0x4394 |
| 8 | Expert | LevelBoard_Expert_ctor | 0x4FD8 |
| 9 | Odd | LevelBoard_Odd_ctor | 0x43B0 |
| 10 | Toob Race | LevelBoard_Toob_ctor | 0x646C |
| 11 | Wobbly | LevelBoard_Wobbly_ctor | 0x4388 |
| 12 | Glass | Board_Glass_ctor | 0x4390 |
| 13 | Sky | LevelBoard_Sky_ctor | 0x47F8 |
| 14 | Master | BoardLevel_Master_Ctor | 0x6498 |
| 15 | Impossible | Board_Impossible_ctor | 0x4380 |
Also saves score, computes difficulty time bonus (+1000ms normal, +500ms frenzied), stores race timestamps. When param_1=true, creates TourneyMenu instead (retry current race).
PracticeMenu_ctor (0x42EA30)
"Practice Menu" scene with 14 race items and 14 thumbnail textures (practice-level1..practice-impossible.png). Lock check via App+0x851-0x865 boolean flags.
Level Unlock Flags (App offsets)
| Offset | Level |
|---|---|
| +0x851 | Level 1 (Warm-Up) |
| +0x852 | Level 2 (Beginner) |
| +0x853 | Level 3 (Intermediate) |
| +0x854 | Level 4 (Dizzy) |
| +0x855 | Level 5 (Tower) |
| +0x856 | Level 6 (King of the Hill) |
| +0x857 | Level 7 (Up) |
| +0x858 | Level 8 (Expert) |
| +0x859 | Level 9 (Odd) |
| +0x85A | Level 10 (Toob Race) |
| +0x85B | Level 11 (Sky) |
| +0x85C | Level 12 (Wobbly) |
| +0x85D | Level 13 (Master) |
| +0x85E | Level 14 (Race of Ages) |
| +0x85F | Level 15 (Impossible) |
| +0x860-0x865 | Arena unlock flags |
Scene_SpawnBallsAndObjects (vtable[32], 0x41C5B0)
Creates GameObject instances (0xC60 bytes) for each ball, sets start positions via START%d-%d naming, scans SAFESPOT/SAFEPOS objects, creates BadBall/MouseTrap/SecretObjects/Flags/Signs/DynamicObjects.
App_ShowResults (0x428060)
Creates results screen scene (0x87C bytes). Called after tournament race completion.
Difficulty_GetTimeModifier (0x428ED0)
Returns time bonus multiplier based on difficulty level.
ArenaBoard Arena Init Functions (14 total — CORRECTED)
| Arena | Init Function | Level Path | Special Logic |
|---|---|---|---|
| WarmUp | ArenaLevel_WarmUp_Init (0x413C20) | levels\arena-WarmUp | — |
| Beginner | ArenaBoard_Beginner_Init (0x414180) | levels\arena-intermediate | — |
| Intermediate | ArenaLevel_Intermediate_Init (0x414180) | levels\arena-Intermediate | — |
| Dizzy | ArenaLevel_Dizzy_Init (0x414240) | levels\arena-dizzy | Loads bonus level Levels\Level3-Swirl |
| Tower | ArenaLevel_Tower_Init | levels\arena-Tower | — |
| Up | ArenaLevel_Up_Init | levels\arena-Up | — |
| Expert | ArenaLevel_Expert_Init (0x414B10) | levels\arena-expert | — |
| Odd | ArenaLevel_Odd_Init (0x414CE0) | levels\arena-Odd | — |
| Sky | ArenaLevel_Sky_Init (0x4158C0) | levels\arena-Sky | "PILLAR" object collection |
| Neon | ArenaLevel_Neon_Init (0x416F40) | levels\arena-neon | 2x AthenaList transforms + boundary sphere |
| Glass | ArenaLevel_Glass_Init (0x417DF0) | levels\arena-Glass | — |
| Master | ArenaLevel_Master_Init (0x416080) | levels\arena-Master | — |
| Race of Ages | (Cascade) | levels\arena-Cascade | — |
| Impossible | ArenaLevel_Impossible_Init (0x418540) | levels\arena-impossible | — |
Scene Vtable Discovery Technique
Scene vtable at 0x4D0260 has 36 entries (144 bytes). Read memory at the vtable address, decode as 36 little-endian 32-bit pointers. Some entries may be 3-byte nop stubs (0x409D90) — these are unused slots. Plate comments can ONLY be set at function addresses, not data addresses (failed at 0x4D0260 and 0x4D934C).
Workflow: High-Xref FUN_* Function Identification
- Use
search_functions_enhancedwithhas_custom_name=false,min_xrefs=15,sort_by=xrefs_descto find undocumented functions with many callers - Batch decompile the top candidates (4-6 at a time via individual decompile_function calls — batch_decompile often fails with "Function not found")
- Identify patterns: AthenaString_, Scene_, Graphics_, MWParser_, Vec3*, etc.
- Cross-reference callers via
get_function_callersorget_xrefs_toon data items to understand usage context - Rename with descriptive Module_Action format, set plate comments
- Key data items can also be labeled:
list_data_items_by_xrefsreveals high-value globals likeg_renderIndex, string constants likes_BACK
New Functions (Session 2026-04-13 Batch 4 — Menu System, Scene/Texture, Game Flow)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x42f810 | TimeTrialMenu_ctor | 3 | "Time Trial Menu" — extends PracticeMenu with race items + lock checks |
| 0x42fc10 | PartyMenu_ctor | 3 | "CHOOSE A PARTY RACE!" — extends PracticeMenu, vtable 0x4D4738 |
| 0x42fc40 | ArenaMenu_ctor | 3 | Arena menu with 14 arena items (Warm-Up to Impossible), lock icons, vtable 0x4D47B8 |
| 0x4326d0 | MPMenu_ctor | 3 | Multiplayer menu: Party Race, Rodent Rumble, controller config (1-4P) |
| 0x44aa40 | Scene_FindTextureByName | 4 | Find texture by case-insensitive name, return ptr+dimensions+width |
| 0x44ab00 | Scene_FindTextureDimensions | 4 | Find texture and measure text width via Font_MeasureText |
| 0x44abf0 | Scene_AddTextureToList | 4 | Add texture reference to scene list by name, mark dirty at +0xCBC |
| 0x443ac0 | SceneObject_RenderScaled | 3 | Render object scaled (ScaleX, SetPosition, vtable callbacks + Timer) |
| 0x4410c0 | SceneObject_FreeStrings | 3 | Free 2 string ptrs, re-init BaseObject, call SceneObject_dtor |
| 0x453c50 | Texture_RemoveRef | 3 | Decrement texture refcount, remove from cache and free when 0 |
| 0x457a50 | Graphics_DisableRenderState | 3 | Thunk → Graphics_SetRenderState (disable mode) |
| 0x428c50 | App_StartPracticeRace | 3 | Start practice/tournament race: calls App_StartRace, PlayerProfile, Tournament_AdvanceRace |
| 0x434580 | Sound_InitChannels | 3 | Allocate sound channels, get next sample, play 3D positioned, set timer 0x140 |
| 0x43b6f0 | Rotator_AddBall | 3 | Find score by ID and set to 10, or create new entry with value 10 |
| 0x44bef0 | Timer_Decrement | 4 | Timer tick: value = end - 100, set flag at +0x2A |
| 0x448620 | ScoreDisplay_DeletingDtor | 3 | ScoreDisplay scalar deleting destructor |
| 0x4470d0 | ScoreDisplay_dtor | 3 | Clean up: free strings, timers, 5 BaseObjects, SceneObject_dtor |
| 0x44acb0 | UIList_Clear | 4 | Empty stub (returns 0) |
Menu System — Arena Unlocks (from ArenaMenu_ctor at 0x42fc40)
App offsets for arena unlock flags (0-based, boolean):
+0x852=Level 2 (Beginner), +0x853=Intermediate, +0x85A=Dizzy, +0x85B=Tower, +0x85C=Up, +0x85D=Expert, +0x85E=Odd, +0x85F=Toob, +0x860=Wobbly, +0x867=Glass, +0x861=Sky, +0x862=Master, +0x868=Impossible
App_StartPracticeRace (0x428c50) — Key Findings
- Calls App_StartRace first, then resolves scene objects by checking priority values (field+0x525 offsets)
- Sets mirror mode culling: App+0x7D2 (0=normal, 1=mirror), calls Gfx_SetCullMode
- Adjusts viewport dimensions from App+0x27C/280/284 and +0x288/28C/0x290
- Creates 0x528-byte scene object at App+0x90C (Tournament scene manager?)
- Creates PlayerProfile (0x98 bytes) at App+0x220, copies level name from App+0x29B4
- Flags set: App+0x717=1, App+0x7B7=1, App+0x5D7=0, App+0x677 (0 unless mirror)
- When mirror: App+0x677=0, +0x23C=1
- Calls Tournament_AdvanceRace(param_1, 0)
Batch Workflow Efficiency Notes
- The batch_decompile tool often fails with "Function not found" — prefer individual decompile_function calls
- For menu constructors: string references ("Time Trial Menu", "CHOOSE A PARTY RACE!", etc.) are the most reliable identifiers
- Arena lock flags at App+0x851-0x868 were confirmed from ArenaMenu_ctor decompilation
- Scene texture functions (FindTextureByName, FindTextureDimensions, AddTextureToList) share a common pattern: iterate AthenaList at +0x890 with index tracking at +0x894
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x466C70 | AthenaString_Format | 98 | sprintf wrapper returning internal buffer |
| 0x4BBDFD | AthenaString_Sprintf | - | Internal sprintf using FILE struct trick |
| 0x469990 | Scene_AddObject | 77 | Add SceneObject to scene (uniqueness check, vtable notify) |
| 0x46F390 | Scene_BeginFrame | 39 | Begin rendering frame (Graphics_BeginFrame + vtable callback) |
| 0x460DA0 | Scene_RenderFrame | 38 | Full scene render pipeline with z-buffer interleaving |
| 0x461370 | Scene_RenderOpaque | 38 | Render opaque pass (object vtable[0x28] + mesh buffers) |
| 0x461890 | Scene_LoadMeshWorld | 38 | Load .meshworld from stream (materials, textures, objects) |
| 0x461F00 | Scene_Subdivide | 38 | Subdivide scene space into regular 3D grid |
| 0x462100 | Scene_SubdivideRandom | 38 | Subdivide scene with random grid positions |
| 0x4629E0 | Scene_LoadCached | 37 | Load .cached scene file (binary format) |
| 0x46A750 | Window_Notify | 37 | Send WM_COPYDATA message to window |
| 0x498200 | BitStream_ReadBits | 95 | Read N bits from byte-aligned stream |
| 0x4792FB | D3DX_DetectShaderProfile | 48 | Detect pixel shader version (1.x/2.x/3.x) |
| 0x492BDA | Vertex_Transform | 32 | Transform vertices between coordinate spaces |
| 0x4737F0 | AthenaString_Assign | 52 | String copy/assign operator |
| 0x4605E0 | AthenaHashTable_Lookup | 36 | Case-insensitive hash table lookup |
| 0x4691C0 | SceneObject_dtor | 31 | SceneObject destructor |
| 0x4693C0 | Scene_AddAllObjects | 25 | Batch add all SceneObjects |
| 0x469600 | MWParser_ReadTag | 24 | XML/SGML tag parser for MW files |
| 0x4695D0 | StreamReader_dtor | 24 | Close file handle, free buffer |
| 0x45D450 | Sprite_DrawColoredRect | 23 | Draw colored quad with random vertex colors |
| 0x472AF0 | AthenaString_Init | 18 | Default string constructor |
| 0x472F30 | RegKey_Close | 18 | RegCloseKey wrapper |
| 0x473670 | AthenaString_CopyCtor | 16 | String copy constructor |
| 0x4740D0 | AthenaString_WriteTag | 16 | Build XML tag |
| 0x489610 | Pool_FreeList | 16 | Walk free list, decrement refcounts |
| 0x459B24 | Graphics_InitShaderDispatch | 19 | D3DX shader init dispatch thunk |
| 0x45A439 | Graphics_SetRenderState | 29 | Render state dispatch thunk |
| 0x4AB9B8 | DivCeil | 15 | Ceiling division (a-1+b)/b |
| 0x4D2334 | s_BACK | 15 | "BACK" string constant |
| 0x5341CC | g_renderIndex | - | Global render index counter |
Documentation Status (2026-04-13 Session 20)
- Total functions: 3,811
- Documented: 2,291 (60.1%)
- User-labeled: 400+ functions
- Progress from baseline: +121 functions documented (was 42.8% at start)
- Session 20 batch: BitStream_ReadBitsSSE2, Matrix_Inverse4x4_SSE2, SSE2_SetFPControlWord, Mem_Zero, Malloc_OrLongjmp, StrCat_Fast, BitStream_CopyToOutput, Noop2, ReturnZero, D3DX_ShaderDispatch0/1/2, AthenaList_Ctor, Audio_StopChannel, App_Shutdown, NetworkConnection_Ctor, Font_RenderToTextureComplex, Gadget_LabelCtor, UIWidget_HitTest, SceneObject_EmptyListCtor, AthenaString_Reserve, AthenaString_Length, AthenaString_Find, D3DX_RegistryGetter, CRC32_Compute, Vec3_ClosestPointOnLine, Texture_StreamRead, Texture_BinarySearch, Texture_LoadFromStream, Texture_dtor, Texture_SetPool, Pool_dtor, Texture_ValidateOrBuild, Mesh_dtor, Pool_Free, Texture_ComputeChecksum, Texture_DestroyBuffers, Mesh_Init, Pool_Alloc, VertexDeclaration_dtor, VertexShader_dtor, Gfx_ResizeBuffers
- Batch workflow: see
references/batch-doc-workflow.mdfor efficient decompile→identify→rename→comment→commit cycle
Session 2026-04-13 Key Findings
- Ball_ApplyForceV2 (0x4016f0): Second force application variant with gravity plane awareness
- Ball_RenderWithCollision (0x402c10): Full collision check + shadow + render pipeline
- Ball_SetupCollisionRender (0x4015b0): Initialize collision mesh render objects from level data
- App_ShowMainMenu (0x4280e0): Creates MainMenu (0xCDC bytes), stores at App+0x224
- App_StartTournamentRace (0x4288b0): Configures mirror mode, creates tournament scene
- RegisterDialog: Complete registration system with serial validation, XOR cipher (key "54138"), writes DXCaps to registry
- Sound_LoadOggOrWav (0x459660): Sound loader with .ogg/.wav fallback pattern
- Sound_GetNextChannel (0x459810): Circular buffer channel allocator
- Graphics_Initialize: Full 27-step D3D8 init sequence documented (adapter enumeration, mode selection, dual device creation, VB setup)
- Graphics object offsets confirmed: +0x7c=D3D8, +0x154=VertexBuffer, +0x164=PrimaryPP, +0x174=SelectedMode, +0x7d3-7d6=ResolutionAvailable flags
- App_StartTournamentRace reveals App offsets: +0x237=flag, +0x717=flag, +0x7b7=flag, +0x5d7=flag, +0x677=flag, +0x236=mirror_mode, +0x27c/280/284=viewport, +0x7d2=mirror_cull_flag
Session 20 — SSE2/Math/CRT/Texture Functions (NEW)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4B22AB | BitStream_ReadBitsSSE2 | 19 | SSE2 bitstream reader: reads N bits in 8-bit chunks, 0xFF marker handling, maintains stream state |
| 0x4A0F3A | Matrix_Inverse4x4_SSE2 | 9 | SSE2 4x4 matrix inverse via cofactor expansion + Newton-Raphson reciprocal. Returns NULL if singular |
| 0x4A6B80 | SSE2_SetFPControlWord | 9 | Store FPU control word to global DAT_00535280 |
| 0x4ABA49 | Mem_Zero | 8 | Optimized memset-to-zero: dwords first (count>>2), then bytes (count&3) |
| 0x4AD576 | Malloc_OrLongjmp | 9 | Safe malloc: calls malloc, longjmps with "Out of Memory" on failure. Returns NULL if ptr/size==0 |
| 0x4BC360 | StrCat_Fast | 9 | Optimized strcat: dword-aligned null detection (0x7efefeff trick), fast copy |
| 0x4B8564 | BitStream_CopyToOutput | 8 | Copy data from bitstream to output buffer with checksum callback. Double-buffered streaming |
| 0x4C183E | Noop2 | 8 | Empty no-op function |
| 0x4C7152 | ReturnZero | 8 | Returns 0 (stub/trap) |
| 0x459D96 | D3DX_ShaderDispatch0 | 3 | D3DX shader dispatch via PTR_FUN_004F7194 vtable |
| 0x459E34 | D3DX_ShaderDispatch1 | 4 | D3DX shader dispatch via PTR_FUN_004F71B8 vtable |
| 0x459ED1 | D3DX_ShaderDispatch2 | 4 | D3DX shader dispatch via PTR_FUN_004F71CC vtable |
| 0x467E40 | AthenaList_Ctor | 3 | Init AthenaList with vtable 0x4E998C, StdString_Substr copy |
| 0x46A0D0 | Audio_StopChannel | 9 | Stop BASS audio channel (BASS_ChannelStop on handle at +0x08) |
| 0x46DB10 | App_Shutdown | 4 | Application cleanup: destroy window, release 5 COM objects, CoUninitialize, free string buffer |
| 0x46DFA0 | NetworkConnection_Ctor | 4 | Init network connection: "Not Connected", id at +0x04, +0x0C=1.0f |
| 0x472340 | Font_RenderToTextureComplex | 4 | Complex text→texture rendering using D3D vertex buffers and shaders |
| 0x472990 | Gadget_LabelCtor | 8 | Gadget_Label constructor: vtable 0x4D9E68, name string at +0x878, value at +0x87C |
| 0x469B20 | UIWidget_HitTest | 3 | Hit-test UI widget list against (x,y) point. Checks +0x420 rect first, else iterates children by bounds |
| 0x46B840 | SceneObject_EmptyListCtor | 3 | Init SceneObject with DeletingDtor vtable 0x4D9368 + empty AthenaList |
| 0x473480 | AthenaString_Reserve | 4 | Reserve string capacity: alloc new buffer, copy old, free old |
| 0x473600 | AthenaString_Length | 4 | Recalculate and cache string length, clear dirty flag at +0x10 |
| 0x473640 | AthenaString_Find | 4 | strstr wrapper: returns offset of substring or -1 |
| 0x477010 | D3DX_RegistryGetter | 4 | Set vtable 0x4DA65C (DirectX registry path struct) |
| 0x475DC0 | CRC32_Compute | 3 | CRC32 using 256-entry lookup table at DAT_004F7534 |
| 0x477670 | Vec3_ClosestPointOnLine | 3 | Project point onto line segment. Clamp t to [0,lineLength] |
| 0x477970 | Texture_StreamRead | 3 | Stream read from bitstream with 0x400-byte blocks and progress callback |
| 0x477AC0 | Texture_BinarySearch | 4 | Binary search in texture archive with checksum and callback |
| 0x477D60 | Texture_LoadFromStream | 4 | Texture loading from stream: init pool, loop with D3DX_Uninit/retry, up to 3 attempts |
| 0x478680 | Texture_dtor | 4 | Destroy texture: free sub-textures, pixel buffers, palette, alpha, vtable release |
| 0x48A160 | Texture_SetPool | 6 | Set texture pool reference (param_2), clear struct |
| 0x48A180 | Pool_dtor | 6 | Free pool list, zero 8 dwords |
| 0x48A560 | Texture_ValidateOrBuild | 5 | Validate/assemble texture from stream chunks, checksum mismatch → -0xF3 |
| 0x48A900 | Mesh_dtor | 6 | Destroy mesh: free D3D vertex buffers, release texture refs, free allocations |
| 0x489DF0 | Pool_Free | 4 | Free pool entries, zero 0x14 dwords |
| 0x489E20 | Texture_ComputeChecksum | 4 | Compute texture checksum from stream bytes (0xFF skip marker, accumulate sum) |
| 0x48A860 | Texture_DestroyBuffers | 5 | Free D3D index/vertex buffers, free allocation ptrs |
| 0x48A8D0 | Mesh_Init | 6 | Zero mesh struct, calloc 0xCA8 bytes for mesh data |
| 0x48B1C0 | Pool_Alloc | 6 | Pool allocator: allocate aligned block, chain old block, return offset |
| 0x48B2A0 | VertexDeclaration_dtor | 3 | Free vertex declaration, release D3D decl, zero struct |
| 0x48B530 | VertexShader_dtor | 3 | Free vertex shader: release D3D shader, free texture/palette/alpha buffers |
| 0x480C4D | Gfx_ResizeBuffers | 5 | D3D texture/surface buffer resize with format detection (D3DFMT checking) |
High-Xref Utility Functions
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4BA754 | __ftol2 | 358 | CRT float-to-int64 conversion (compiler intrinsic) — MOST CALLED FUNCTION |
| 0x45DD60 | RNG_Rand | 193 | PRNG with 55-entry circular buf (Mitchell & Moore). Returns (buf[read]+buf[write])>>6 % range. Signed mode: param_2=1 negates 50% of results |
| 0x453250 | Vec3List_Free | 283 | Free Vec3List + member data at +0x103 |
| 0x44B840 | Noop | 280 | Empty stub (vtable placeholder) |
| 0x4736B0 | AthenaString_dtor | 85 | AthenaString destructor — frees buffer, sets vtable to base dtor (0x4D290C) |
| 0x473500 | AthenaString_AssignCStr | 75 | AthenaString assign from C string |
| 0x536A0 | AthenaList_GetSize | 60 | Return count at +4 |
| 0x453640 | AthenaList_FindByValue | - | Linear search, returns index or -1 |
| 0x443990 | AthenaString_Clear | 14 | Free buf, reset to 15-char inline capacity |
| 0x4531E0 | Vec3_Init | 13 | Set Vec3 vtable + zero + w=255.0 |
| 0x4BC0D1 | strtok | 50 | CRT strtok — thread-safe string tokenizer |
| 0x4BAC20 | strstr | 22 | CRT strstr — string search with SIMD optimization |
| 0x4BAE43 | AthenaString_SprintfToBuffer | 71 | sprintf into char buffer via fake FILE struct |
UI List System (vtable 0x4D6A70, base for ALL menus)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x448F20 | SimpleMenu_ctor | 15 | "Simple Menu" base with item list + scrollers |
| 0x4490A0 | UIListItem_ctor | - | Init 0x444-byte item (Vec3 + AthenaList) |
| 0x4497F0 | UIList_AddItem | 86 | Add named item (text, subtext, colors, icon, height) |
| 0x449430 | UIList_AddSpacer | 29 | Add empty row with height |
| 0x4494D0 | UIList_ScrollUpdate | 17 | Scroll, mouse wheel, vtable dispatch |
| 0x449B00 | UIList_Cleanup | 27 | Free all items (strings, SceneObjects, Vec3Lists) |
| 0x449C20 | UIList_HandleKeyNav | 18 | Up/down/pgup/pgdn key navigation |
| 0x449D40 | UIList_Render | 18 | Draw items: gradient bars, text, icons, scroll arrows |
| 0x44A570 | UIList_Layout | 18 | Compute widths, position SceneObjects, set scrollers |
| 0x44A8B0 | UIList_SetTextByName | 27 | Find item by subtext, replace display text |
| 0x449750 | UIList_ActivateCurrentItem | 18 | Activate: Back→sound 650, Continue→50, else callback |
UIListItem (0x444 bytes): +0x00=display_text, +0x04=subtext, +0x0C-0x0F=RGBA, +0x1C4=icon, +0x244=height, +0x110=is_icon_row flag, +0x441=highlighted flag
ArenaBoard System (vtable PTR 0x4D1358, extends Board extends Scene)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x4217B0 | ArenaBoard_ctor | 15 | Init with "ArenaBoard", timer, base score=6000 |
| 0x421880 | ArenaBoard_dtor | 24 | Cleanup timer, release SceneObjects, Scene_dtor |
| 0x421910 | ArenaBoard_Render | 16 | Timer bar, round ".%d", "TIE BREAKER!" |
| 0x421FE0 | ArenaBoard_Update | 16 | Check round end, resolve ties, "Game Over" music |
| 0x458E60 | ToggleTimer_Init | 12 | Initialize round timer |
| 0x458E80 | ToggleTimer_Cleanup | 32 | Cleanup round timer |
| 0x458E90 | ToggleTimer_Tick | 12 | Tick countdown |
| 0x44AD50 | ArenaScoreParticle_ctor | 12 | Init vtable + difficulty scale [0.02, 0.03, 0.04] |
Key ArenaBoard offsets: +0x47AC=base_score(6000), +0x47C5=is_tie_breaker, +0x47CC=tie_active, +0x47D0=max_rounds(25), +0x11EB=round_end_timer, +0x11F1=game_over_flag, +0x11ED-0x11F0=4 player scores
Graphics Transform Pipeline (Session 2026-04-13)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x457B10 | Matrix44_Zero | 12 | Clear 4x4, set diagonals to 1.0 |
| 0x457B50 | Gfx_SetPosition | 69 | D3D SetTransform world translate |
| 0x457BB0 | Gfx_RotateY | 15 | Rotation around Y axis |
| 0x457C60 | Gfx_ScaleX | 40 | Scale X axis |
| 0x457C90 | Gfx_ScaleY | 35 | Scale Y axis |
| 0x457CC0 | Gfx_ScaleZ | 26 | Scale Z axis |
| 0x457FD0 | Matrix4_Identity | 40 | Set identity vtable |
| 0x425FE0 | Gfx_SetAlphaBlendState | 9 | D3DRS_SRCBLEND/DESTBLEND mode 3 |
| 0x427940 | Gfx_SetCullMode | 13 | D3DRS_CULLMODE (none/CW/CCW) |
Wave Math System
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x457DA0 | Wave_Sin | 38 | sin(time * freq * 2π/360) |
| 0x457DC0 | Wave_Cos | 23 | cos(time * freq * 2π/360) |
Sprite System (vtable 0x4D8F84)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x45D0C0 | Sprite_ctor | 30 | Init with texture, RenderContext, material defaults |
| 0x45D660 | Sprite_RenderQuad | 15 | Render textured quad via material + DrawPrimitive |
Sprite (0xD4 bytes): +0x00=vtable, +0x04=gfx, +0x08-0x60=RenderContext, +0x50=texture, +0xC8=width, +0xCC=height, +0xD0=visible(1), +0xD1=flag
Scene Rendering Extensions (Session 2026-04-13)
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x45E0E0 | Scene_RenderAllObjects | 33 | Main 3D render: BeginFrame → sort (opaque/alpha/shadow) → draw |
| 0x460450 | Scene_RenderBallShadow | 38 | Ball shadow with depth bias pass |
| 0x45DF80 | SceneObject_CallUpdate | 47 | Dispatch +0x434 vtable[1] (Update) |
| 0x45DF90 | SceneObject_CallRender | 47 | Dispatch +0x434 vtable[2] (Render) |
| 0x437130 | Scene_StartCountdown | 11 | Start countdown (3..2..1, 400 or 50 frames) |
Object flags in Scene_RenderAllObjects: +0x85F=shadow, +0x860=alpha, +0x862=deferred, +0x863=skip
PRNG (RNGState struct)
struct RNGState {
VTable* vtable; // +0x00
int read_ptr; // +0x04 (wraps at 55)
int write_ptr; // +0x08 (wraps at 55)
uint32_t buffer[55]; // +0x0C..0xE4 (circular buf)
};
// Algorithm: buf[read] = (buf[read] + buf[write]) & 0x3FFFFFFF
// Return: (result >> 6) % range
// Signed: if param_2==1 && Rand(2)==0, negate
Dialog System
| Address | Name | Xrefs | Description |
|---|---|---|---|
| 0x440E70 | OkayDialog_ctor | 12 | "Okay Dialog" with caption + "OKAY!" button |
| 0x401480 | GameObject_dtor | 9 | Release timers, free Vec3Lists, cleanup matrices |
Phase 3: System Documentation
After achieving 100% function naming (3781/3781), the project moved to comprehensive
system documentation. Key docs created:
| Doc | Content |
|---|---|
| docs/RUMBLEBOARD_SYSTEM.md | 15-race tournament order, all 9 arena asset paths, timer system, HUD layout, menu commands, mirror unlock, time bonus |
| docs/UI_MENU_SYSTEM.md | Menu hierarchy (SimpleMenu/UIList), all dispatch tables (Main/Pause/Difficulty), color system, dialog classes |
| docs/ARENA_HAZARD_SYSTEM.md | 6 hazard types (Sawblade/Tower/Spinner/Gear/Bell/Bonk), CreateExpertLevelObjects factory, name suffix modifiers, difficulty values |
| docs/LEVEL_OBJECTS.md | Catapult/Trapdoor/ScoreDisplay/HighScore/Damage/JumpPad, RNG (55-element LFSR), Ball trail particles |
| docs/COLLISION_SYSTEM.md | CollisionFace/MeshBuffer structs, .COL binary format, Arena/Level/GameObject event dispatch |
Quick Analysis Commands
Using GhidraMCP Headless Server — PRIMARY analysis tool
# Start the headless server (run once per session):
export GHIDRA_HOME=/opt/ghidra_12.0.4_PUBLIC
MCP_JAR=/home/evan/.config/ghidra/ghidra_12.0.4_PUBLIC/Extensions/GhidraMCP/lib/GhidraMCP-5.12.0.jar
CLASSPATH="$MCP_JAR"
for jar in $GHIDRA_HOME/Ghidra/Framework/*/lib/*.jar; do CLASSPATH="${CLASSPATH}:${jar}"; done
for jar in $GHIDRA_HOME/Ghidra/Features/*/lib/*.jar; do CLASSPATH="${CLASSPATH}:${jar}"; done
for jar in $GHIDRA_HOME/Ghidra/Processors/*/lib/*.jar; do CLASSPATH="${CLASSPATH}:${jar}"; done
java -Xmx4g -XX:+UseG1GC \
-Dghidra.home=$GHIDRA_HOME -Dapplication.name=GhidraMCP \
-classpath "$CLASSPATH" \
com.xebyte.headless.GhidraMCPHeadlessServer \
--port 8089 --bind 127.0.0.1 &
# Load binary and run analysis:
curl -s -X POST -d "file=/home/evan/hamsterball-re/originals/installed/extracted/Hamsterball.exe" http://127.0.0.1:8089/load_program
curl -s -X POST http://127.0.0.1:8089/run_analysis
# Read operations (GET):
curl -s "http://127.0.0.1:8089/decompile_function?address=0x004278E0"
curl -s "http://127.0.0.1:8089/list_functions?limit=50"
curl -s "http://127.0.0.1:8089/search_strings?search_term=App&limit=10"
curl -s "http://127.0.0.1:8089/get_xrefs_to?address=0x004D9384"
curl -s "http://127.0.0.1:8089/list_imports?limit=200"
# Write operations (POST with JSON body — form-encoded does NOT work):
curl -s -X POST -H "Content-Type: application/json" \
-d '{"function_address":"0x00455380","new_name":"Graphics_Initialize"}' \
http://127.0.0.1:8089/rename_function_by_address
# IMPORTANT: API param naming uses underscores (function_address, new_name, search_term)
# IMPORTANT: POST writes require JSON body, form-encoded params silently fail
Using r2 (supplementary — good for quick checks)
cd ~/hamsterball-re/originals/installed/extracted
r2 -q -c "aaa; afl" Hamsterball.exe | wc -l # 1869 functions
r2 -q -c "aaa; s 0x429530; pdf" Hamsterball.exe # App::Initialize
r2 -q -c "aaa; axt @ sym.imp.d3d8.dll_Direct3DCreate8" Hamsterball.exe
MESHWORLD File Format (CONFIRMED)
Binary Structure
[Geometry Section] — vertex/face data, variable length
[Object Section] — starts with uint32 count, then objects
[Trailer] — closing data
Object Format
Each object: [uint32 str_len][type_string][data...][optional_texture_string]
Simple objects (START, SAFESPOT): 28 bytes after type string
- 12 bytes: position (3 floats: x, y, z)
- 16 bytes: rotation/flags (4 values)
Complex objects (FLAG, PLATFORM, BUMPER): variable size
- 12 bytes: position
- 16 bytes: rotation/flags
- 32 bytes: transform matrix (8 floats, identity=1.0)
- 16 bytes: diffuse color (4 floats: RGB + alpha)
- 16 bytes: ambient color (4 floats)
- 4 bytes: size parameter (e.g., 35.0 for platforms)
- 8 bytes: flags (2 uint32)
- [4+N bytes: texture string (length-prefixed)]
- [face index data: pairs of uint32]
Object Types
START1-1, START2-1, START2-2, START2-3, START2-4 — Player start positions
FLAG02, FLAG04, FLAG06, FLAG07 — Checkpoint flags
SAFESPOT — Safe landing zones
CAMERALOOKAT (CameraLocus) — Camera targets
PLATFORM, N:SINKPLATFORM — Level geometry
N:BUMPER1/2/3/4 — Bumpers
E:NODIZZY — Anti-dizzy zones
E:LIMIT — Arena limits
E:GROWSOUND — Sound triggers
Parsing Approach (IMPORTANT)
The format is NOT straightforward sequential parsing — object data sizes vary per type and can't be determined without knowing the type. The correct approach is:
- Scan entire file for all length-prefixed strings
- Classify each as "primary type" or "texture"
- Primary strings define new objects, texture strings attach to the preceding object
- Read position data from the bytes following each primary string
- Transform/material data lives between the main type string and the texture string
Parser Test
cd ~/hamsterball-re/reimpl
# Build and test (86 levels, 1533 objects confirmed)
./build/level_viewer ~/hamsterball-re/originals/installed/extracted/Levels/Level1.MESHWORLD
# Headless test with Xvfb:
DISPLAY=:99 timeout 3 ./build/level_viewer <level_file>
Running the Game
cd ~/hamsterball-re/originals/installed/extracted
wine Hamsterball.exe # Needs X display for rendering
App::Run Game Loop (0x46BD80) — CONFIRMED
1. while (!quitFlag):
2. if PeekMessage(&msg, NULL, 0, 0, PM_REMOVE):
3. if msg == WM_QUIT: quitFlag = 1; break
4. TranslateMessage(&msg); DispatchMessage(&msg)
5. else:
6. frameStart = GetTickCount()
7. this->Update() // vtable call (0x469CF0 = GameUpdate)
8. this->Render() // vtable call
9. this->Present() // vtable call (0x455A90 = Graphics_PresentOrEnd)
10. frameEnd = GetTickCount()
11. elapsed = frameEnd - frameStart
12. if elapsed < targetFrameTime: Sleep(targetFrameTime - elapsed)
13. if elapsed > 0: this->fpsDenominator = 33 / elapsed // frame scaling
Pitfalls / Lessons Learned
CRITICAL: Always Backup Renames to Git
- The Ghidra project DB is NOT version controlled
- If the project is re-imported or gets corrupted, ALL renames are LOST
- This happened April 2026: dropped from 75.5% to 40% documented
- After every RE session: run
ghidra-rename-exportskill to backup toanalysis/ghidra/renames_backup.json - If DB lost: run
ghidra-restore-renamesskill to restore fromdocs/FUNCTION_MAP.md - Server restart: use
ghidra-mcp-headlessskill with--program /Hamsterball.exe(leading slash required) - Related skills:
ghidra-rename-backup,ghidra-restore-renames,ghidra-mcp-headless,ghidra-rename-export
Binary Format RE — String Scanning Works Best
- Sequential parsing of MESHWORLD failed because object sizes are variable per type
- Scanning for all length-prefixed strings first, then building objects from the string list, is the only reliable approach
- The string
[uint32 length][ASCII data]pattern is very robust for finding object boundaries
GhidraMCP Headless Server — Key Gotchas
- POST writes require JSON body: Form-encoded params (
-d "key=value") silently fail with "parameter required" errors. Must use-H "Content-Type: application/json" -d '{"key":"value"}' - Param names use underscores:
function_addressnotfunctionAddress,new_namenotnewName,search_termnotsearchTerm - Import addresses are EXTERNAL:ordinal: Not regular program addresses, so
get_xrefs_towon't work on them. Use the string name (e.g., "BASS_Init") to find the IAT thunk, then find xrefs to the thunk. - Ghidra GUI + MCP plugin is fragile: The plugin requires the GUI CodeBrowser to be open with the project loaded. The headless server (
GhidraMCPHeadlessServer) is much more reliable for automation. - MCP bridge (bridge_mcp_ghidra.py) discovers instances via Unix domain sockets in
/tmp/ghidra-mcp-{user}/*.sock. The headless server uses TCP on port 8089 instead. SetGHIDRA_SERVER_URLenv var or useconnect_instancewith the TCP URL.
GhidraMCP Struct Creation — JSON Fields Format
create_structfields MUST be JSON array format:[{"name":"field_name","offset":0,"type":"float"},...]— colon/comma string formats all fail with "No valid fields provided"add_struct_fieldworks with explicit offset: Use for incremental building of large structsimport_data_typesnot implemented: Returns "Import functionality not yet implemented"run_script_inlinebroken: "BundleHost null" OSGi error since session 47 — usecreate_struct/add_struct_fieldinstead- Valid type strings:
float,int,uint,byte,char,uint32,int32,byte[20],uint(pointer), or any Ghidra built-in type name - Workflow for large structs:
create_structwith initial minimal fields (0-32 bytes), thenadd_struct_fieldper field with explicit offset — this avoids size conflicts when adding overlapping fields
Ghidra Issues
- -readOnly discards all changes: The
-readOnlyflag in analyzeHeadless discards labels/analysis on exit. Always omit it. - Python scripts fail in headless: "Ghidra was not started with PyGhidra"
- GUI won't start in headless env: Xvfb doesn't work reliably for Ghidra's Swing UI
- Use GhidraMCP headless server instead: Full 191 REST API endpoints, no GUI needed
Function Discovery Workflow
- Search for game-specific strings:
search_strings?search_term=X - Find xrefs to those strings:
get_xrefs_to?address=0x{addr} - Cross-reference to identify functions: each xref tells you which function references it
- Decompile to confirm:
decompile_function?address=0x{addr} - Rename:
rename_function_by_addresswith JSON body
Vtable Discovery Technique (CRITICAL)
GhidraMCP's get_xrefs_from only returns ONE xref per data address (the first entry). To discover ALL vtable method pointers, scan each 4-byte offset:
for i in range(0, 0x40, 4):
result = get_xrefs_from(address=vtable_base + i)
# Each result links to a function pointer
- Dtor is always at +0x00 (first entry)
- The second entry (+0x04) is typically the Update method in this engine
- Some entries may point to addresses Ghidra hasn't defined as functions (thunks/jumps) — these still exist and can be found by searching nearby function addresses
- The Ghidra headless API returns
"No function found"for undefined thunks — try decompiling nearby addresses to find the real target
Indirect Object Access Pattern
The App global (0x4FD680) has few direct xrefs because the game typically passes this pointers through function calls. To find code accessing specific App fields:
- Find functions that receive App* as
param_1orthis - Look for offset accesses matching the App layout (e.g., +0x174 = Graphics, +0x180 = Input)
- Use string references as anchoring points (debug strings like "App::Initialize(N)")
MESHWORLD Position Values
- Y is typically negative (below Y=0) in world space
- X and Z span from -200 to +200 for arena levels, -1500 to +1500 for race levels
- Position values for N:SINKPLATFORM/N:BUMPER may be transform-scale (0,0,0 or 1,1,1) rather than world positions
Input Subsystem (DirectInput8)
keyboard Device Layout (0x524 bytes, vtable 0x4D9840)
+0x00: vtable pointer
+0x04: Input* back-pointer (parent Input object)
+0x08: IDirectInputDevice8* device
+0x0C: key_state_old[256] (DWORD[0x40] — previous frame key states)
+0x10C: key_bindings[256] (pointer[0xFF] — DIK code → action object mapping)
+0x143: KeyDown DIK code (uint8, DIK_* code for Down action)
+0x144: KeyUp DIK code
+0x145: KeyLeft DIK code
+0x146: KeyRight DIK code
+0x147: KeyAction1 DIK code
+0x148: KeyAction2 DIK code
+0x184: ptr to key action object
+0x1CC: ptr to key action object
+0x1C4: ptr to key action object
+0x18C: ptr to key action object
+0x154: ptr to key action object
+0x194: ptr to key action object
+0x198: ptr to key action object
+0x1A0: ptr to key action object
+0x1A4: ptr to key action object
+0x1D0: ptr to key action object
+0x1D4: ptr to key action object
+0x168: ptr to key action object
+0x16C: ptr to key action object
+0x170: ptr to key action object
+0x14C: ptr to key action object
+0x158: ptr to key action object
+0x188: ptr to key action object
+0x15C: ptr to key action object
+0x138: vtable/data pointer
Key Mapping (6 rebindable actions)
- KeyUp (DIK code at KeyboardDevice+0x143)
- KeyDown (DIK code at KeyboardDevice+0x144)
- KeyLeft (DIK code at KeyboardDevice+0x145)
- KeyRight (DIK code at KeyboardDevice+0x146)
- KeyAction1 (DIK code at KeyboardDevice+0x147)
- KeyAction2 (DIK code at KeyboardDevice+0x148)
Input Subsystem Functions
- Input_Init (0x46EE10): DirectInput8Create with device fallback, EnumDevices for keyboard
- Input_Create (0x46C110): Allocates Input object (0x438 bytes), stores at App+0x180
- KeyboardDevice_ctor (0x46E250): Creates DInput keyboard device (0x524 bytes), sets cooperative level, acquires
- KeyboardDevice_dtor (0x46DE60): Releases DI device, unbinds key mappings
- Mouse input uses DirectInput8 mouse device (not yet fully mapped)
Level/Object System
Level Object Layout (0x10D0 bytes, vtable at 0x4D8FB0)
+0x00: vtable pointer
+0x04: AthenaList* object_list (game objects)
+0x08: int object_count
+0x18: AthenaList* (secondary list)
+0x410: void** object_array (pointer to list data)
+0x41C: GameObject* tracked_object_1 (e.g., player ball)
+0x420: GameObject* tracked_object_2
+0x424: AthenaList* managed_list
+0x428: AthenaList* (object pool)
+0x430: uint8 flag_1
+0x431: uint8 flag_2
+0x4368: LevelData* (secondary level — clone for split screen)
+0x436C: Level* level_mesh_1 (MeshWorld)
+0x4370: Level* level_clone_1
+0x4374: Level* level_mesh_2 (secondary)
+0x4378: AthenaList* objects_list_1
+0x4790: AthenaList* objects_list_2
+0x480: LevelState* state (ball/physics state, 0x10D4 bytes)
+0x484: uint8 flag
+0x844: LevelContext* context (shared rendering state)
+0x868: char* level_name (e.g., "Board (Dizzy)")
+0x878: App* back-pointer
LevelState (Ball/Physics) Object (0x10D4 bytes)
+0x04: uint8 enable_flag
+0x14: int counter
+0x18: uint32 value (0xf = 15, possibly ball size param)
+0x1C: AthenaList* (objects)
+0x440: void* collision_data (freed on cleanup)
+0x444: IDirect3DVertexBuffer8* vb_1 (freed on cleanup)
+0x448: IDirect3DVertexBuffer8* vb_2 (freed on cleanup)
+0x44C: IDirect3DVertexBuffer8* vb_3 (freed on cleanup)
+0x450: unknown struct (0x14 bytes)
+0x464: unknown struct (0x14 bytes)
+0x478: AthenaList* (game entities)
+0x894: AthenaList* (render entities)
+0xCAC: AthenaList* (physic entities)
+0x10C4: uint8 flag
+0x10C8: uint32 extra_data (freed on cleanup)
+0x10D0: uint8 flag
Game Object Vtable Layout (inferred from GameUpdate)
+0x00: destructor (called with param=1 for free)
+0x04: Update() — main update tick (called for active objects)
+0x08: Release() / IUnknown Release
+0x30: Render/Cleanup method (called when object removed)
+0x3C: Render(context) — render with level context param
Game Object Layout (common fields)
+0x00: vtable pointer
+0x0B: uint8 removed_flag (non-zero = pending removal)
+0x21D: uint8 inactive_flag (non-zero = skip update)
+0x21A: int object_id (used as context during updates)
+0x21C: padding/flags
GameUpdate (0x469CF0) — Object Lifecycle
- Active pass: Iterate object list, call vtable+4 (Update) for each active object (removed_flag=0, inactive_flag=0)
- Removal pass: For objects with removed_flag set:
- Set LevelContext+0x210 = "Remove Object"
- If tracked_object_1, call LevelContext+0x68 with (-10000,-10000) and LevelState+0x20C=object_id
- Call vtable+0x30 (cleanup render)
- Remove from managed list
- Call vtable+0x3C (render with level context)
- Call vtable+0x00 (destructor) with free=1
- Set LevelContext+0x210 = "Update"
Board/Screen Hierarchy
- Board_ctor (0x419030): Base class, vtable at 0x4D0260
- LevelBoard_Dizzy_ctor (0x41D060): "Dizzy" board, vtable at 0x4D0890
- Board types: Beginner, Intermediate, Dizzy, Tower, Expert, Odd, Wobbly, Toob, Sky, Up, Master
- ArenaBoard: Arena variants (Warmup, Beginner, Intermediate, Dizzy, Tower, Up, Expert)
MESHWORLD Object Types (Complete List)
N: (Named/Physical) Objects
- N:MOUSETRAP, N:TARPIT, N:WATER, N:NOCONTROL, N:UNLOCKSECRET, N:SECRET
- N:BRIDGE, N:SWIRL, N:WHEELEMBED, N:WATERWHEEL, N:MACE
- N:TRAPDOOR, N:JUMPSECOND, N:JUMPFIRST, N:WAVY, N:SQUAREWOBBLY
- N:BUMPER, N:BUMPER%d (numbered), N:SAWTEETH, N:SPINNER
- N:EXTRATIME, N:SPEEDCYLINDER, N:NEONPLATFORM, N:BUMP
- N:TENBONUS2, N:TENBONUS1, N:GLASS, N:ONGEAR, N:ONROTATOR
- N:BOUNCE, N:SINKPLATFORM (also DN:SINKPLATFORM)
E: (Event/Trigger) Objects
- E:TRAJECTORY, E:ACTION, E:JUMP, E:BREAK, E:LIMIT
- E:SAFESWITCH, E:NODIZZY, E:MACETRIGGER, E:BITE, E:OPENSESAME
- E:CATAPULTBOTTOM, E:BELL, E:SCORE, E:ALERTJUDGES
- E:ACTIVATESAW1, E:ACTIVATESAW2, E:ALERTSAW1, E:ALERTSAW2
- E:HAMMERCHASE, E:CALLHAMMER, E:LIMITPIPE2, E:LIMITPIPE1
- E:SWALLOW, E:LIMITX, E:LIMITZ, E:PIPERANDOM, E:DROPLIFT
- E:GROW, E:GROWSOUND, E:SHRINK, E:GRAVITY
- E:BRANCH, E:HEATON, E:HEATOFF, E:NOPEGS, E:PEGS
- E:TRAPPOP, E:VACPOPOUT, E:HELPINERTIA, E:UNHELPINERTIA
- E:LAUNCH, E:LIGHTSON, E:LIGHTSOFF, E:ZOOP
Ball Physics System
Ball Vtable (0x4CF3A0)
The ball is a GameObject subclass with dedicated physics. Base class is GameObject at 0x4CF314.
| Offset | Address | Function | Description |
|---|---|---|---|
| +0x00 | 0x4027F0 | Ball_dtor | Destructor (calls Ball_Cleanup) |
| +0x04 | 0x405100 | (thunk→0x405190) | Update method |
| +0x08 | 0x402DE0 | Ball_CollisionCheck | Per-frame collision check |
| +0x0C | 0x402A70 | (not defined) | Render setup |
| +0x10 | 0x408390 | (not defined) | Unknown |
| +0x14 | 0x401590 | (not defined) | Unknown |
| +0x18 | 0x402650 | Ball_ApplyForce | Apply directional force |
| +0x1C | 0x402C10 | (not defined) | Unknown |
| +0x20 | 0x409480 | (not defined) | Unknown |
Ball Object Layout (0xC98 bytes)
| Offset | Type | Description |
|---|---|---|
| 0x00 | void** | Vtable pointer (0x4CF3A0) |
| 0x04 | int | level reference (parent) |
| 0x08 | int | Parent object reference |
| 0x0C | float[6] | Collision planes (4 floats each: ax+by+cz+d) |
| 0x42 | Timer | Per-ball timer |
| 0x59 | float | Ball X position (legacy) |
| 0x5A | float | Ball Y position (legacy) |
| 0x5B | float | Ball Z position (legacy) |
| 0x62 | float | Ball size (default 3.0f = 0x40400000) |
| 0x69 | void* | Sound object |
| 0x96 | int | Sound handle |
| 0x154 | IDirect3DDevice8* | D3D device pointer |
| 0x164 | float | X position (authoritative) |
| 0x168 | float | Y position (authoritative) |
| 0x16C | float | Z position (authoritative) |
| 0x170 | float | X velocity (zeroed on collision) |
| 0x174 | float | Y velocity (zeroed on collision) |
| 0x178 | float | Z velocity (zeroed on collision) |
| 0x17C | float | Acceleration X (zeroed on collision) |
| 0x180 | float | Acceleration Y (zeroed on collision) |
| 0x184 | float | Acceleration Z (zeroed on collision) |
| 0x1A4 | int | Level state reference |
| 0x284 | float | Ball radius / height offset |
| 0x2CC | byte | Force disable flag |
| 0x2DC | float | X position (secondary) |
| 0x2E0 | float | Y position (secondary) |
| 0x2E4 | float | Z position (secondary) |
| 0x2F0 | int | Frame counter (affects force scaling) |
| 0x2F8 | byte | Active flag |
| 0x2F9 | byte | Collision occurred flag |
| 0x300 | int | Value 0x96 (150) - mass/timer constant |
| 0x310 | byte | State flag (1 = active) |
| 0x314 | float | Home X position |
| 0x318 | float | Home Y position |
| 0x324 | byte | Shrunk/special state flag |
| 0x440 | void* | Collision data |
| 0x6FC | int | Render initialized flag |
| 0x700 | byte | Render mode (affects lighting) |
| 0x708 | int | Render parameter |
| 0x70D | byte | Render state initialized |
| 0x734 | byte | Sub-render mode |
| 0x748 | int | Collision mesh pointer |
| 0x76A | byte | Flag (zeroed on collision) |
| 0x7C8 | int | Render call counter |
| 0x808 | int | Freeze counter (skip force if > 0) |
| 0x810 | AthenaList | Force application list |
| 0xA1 | float | Ball radius (from SIZE param) |
| 0xC74 | int | Collision counter |
| 0xC84 | Quaternion | Object rotation |
Physics Constants (Data Section)
| Address | Value | Description |
|---|---|---|
| 0x4CF368 | float | Collision radius threshold |
| 0x4CF374 | float | Force multiplier (C5C flag set) |
| 0x4CF378 | float | Force multiplier (shrunk state) |
| 0x4CF380 | float | Force scaling (first frame) |
| 0x4CF3E8 | float | Force direction multiplier |
| 0x4CF48C | float | Gravity/height constant |
Key Ball Functions
- Ball_Update (0x405190): 18KB main physics loop. Iterates objects, checks collisions, applies forces, handles reset on collision.
- Ball_ApplyForce (0x402650): Applies force vector (x,y,z,magnitude) with state-dependent multipliers.
- Ball_CheckCollisionPlanes (0x402810): Tests ball against 6 collision planes using formula: distance = Az + By + C*x + D. Returns true if all planes pass (ball inside convex volume).
- Mesh_FindClosestCollision (0x465D90): DDA ray traversal through spatial hash to find closest mesh triangle hit point. Returns 3D position.
- Ball_CollisionCheck (0x402DE0): Per-frame collision check against mesh, increments counter on hit.
- Ball_Render (0x402860): D3D8 render: SetRenderState calls, texture setup, DrawPrimitiveUP.
- Ball_ResetCollisionMesh (0x4030B0): Resets collision mesh and orientation.
| 0x40C5D0 | DispatchCollisionEvents | Main collision event dispatcher — handles ALL object types: E:NODIZZY (grants TIME frames of dizzy immunity), E:SAFESWITCH (with parenthesized data), E:LIMIT (arena unlock tracking per player 0-3), E:BREAK (ball vtable[0x20] bounce), E:JUMP (plays 3D sound, freeze 10 frames, counter=200), E:ACTION (ONCE flag + SCORE award via Difficulty_GetTimeModifier), E:TRAJECTORY (X/Y/Z params set ball trajectory), N:NOCONTROL (disables input 10 frames), N:WATER (sets water flag + 10 frame counter), N:TARPIT (plays 3D sound, marks tar state, clears velocity), N:GOAL (reached goal! plays "Goal!" music, sets flags), N:MOUSETRAP (redirects ball with trap animation using trajectory dir + DAT_004CF370 speed), N:SECRET (marks Rotator triggered), N:UNLOCKSECRET (calls CheckArenaUnlock). DROPIN (checks trajectory magnitude, plays sound, counter=50), PIPEBONK (random sound from 3, counter=10), POPOUT (sound, counter=50) also handled | - Ball_ctor (0x40AFE0): Calls GameObject_ctor, sets vtable to 0x4CF3A0, initializes quaternion to identity
Rendering Pipeline (CONFIRMED from Ghidra)
- Graphics_BeginFrame (0x453B50) → Graphics_RenderScene (0x454BC0) → Graphics_PresentOrEnd (0x455A90)
- Graphics_RenderScene: SetupLights, copy world/view/projection matrices, SetTransform on D3D device, compute frustum for culling, call 8 render callbacks, set Z/stencil/alpha render states
- Graphics_SetViewTransform (0x454A30): Set D3DTS_VIEW matrix, flip for mirror mode (+0x7D2), recompute frustum
- Graphics_SetupFog (0x4539A0): D3DRS_FOGENABLE, FOGVERTEXMODE, FOGSTART, FOGEND, FOGCOLOR — uses linear fog with distance-based scaling
- Graphics_Clear (0x453900): IDirect3DDevice8::Clear — target+Z (3) or Z+stencil (7)
- Graphics_DrawScreenQuad (0x455F40): DrawPrimitiveUP for 2D overlay quads (UI, menus)
- Matrix_ComputeFrustum (0x4762B0): Build view frustum from projection params
- Graphics_InitRenderStates (0x42C810): Set background color
- Level_Update (0x4606D0): Build collision vertex buffer from mesh faces, assemble into D3D vertex format (32 bytes: pos+norm+uv), create spatial hash
- Level_ExtractByMaterial (0x471830): Extract triangles matching material filter, create new MeshWorld, compute AABB bounds
- CreateBadBall (0x40BCA0): Creates 0xC98 byte ball object, parses N:BADBALL params (CHASE, HOME, SIZE)
- Ball_ctor (0x40AFE0): Calls GameObject_ctor, sets vtable to 0x4CF3A0, initializes quaternion to identity
- GameObject_ctor (0x4039E0): Base class ctor, sets vtable to 0x4CF314
Athena List Utility Functions
AthenaList (vtable 0x4D875C) is a generic container class used throughout the engine:
- AthenaList_Init (0x453210): Initialize 256-entry hash table list
- AthenaList_Clear (0x453280): Clear list contents
- AthenaList_GetIndex (0x4532B0): Get current iteration index
- AthenaList_Remove (0x4534D0): Remove item from list
List structure (0x418 bytes):
+0x000: vtable (PTR_FUN_004D875C)
+0x004: next_id / param
+0x008: count (number of items)
+0x00C: iteration counters (int[256])
+0x100: data area (256 entries, zero-initialized)
+0x410: void** data (pointer to dynamic array)
+0x414: uint32 capacity
Texture System
Texture Loading Pipeline
The game embeds libpng, libjpeg, and D3DX8 statically — no external DLL dependencies for image loading.
-
Graphics_LoadTexture (0x455C50): Cache-aware texture loader
- Checks texture cache (Graphics+0x2E4 AthenaList)
- If cached: increments refcount at texture[4]
- If not cached: calls Texture_Create
-
Texture_Create (0x476770): Creates 0x74-byte texture object
- Strips file extension from filename
- Tries multiple file formats:
%s%s-mip1.%s, then without "-mip1" - Attempts up to 3 format variations (PNG, JPG, BMP)
- Uses "textures\" prefix (or custom path if Graphics+0x7D8 set)
- Calls Image_LoadFromFile to actually load
-
Image_LoadFromFile (0x489217): D3DX image file loader wrapper
- FUN_00487E5E: Initialize file handle struct (sets to -1,-1,0,0)
- FUN_00487E70: Read file into memory buffer
- FUN_004891D7 → FUN_00488B64: Create D3D texture from decoded image
-
D3DX_CreateTextureFromMemory (0x488B64): 15KB D3DX texture creation
- Parses image header (IJG JPEG or libpng)
- Creates D3D texture with mipmap chain
- Returns D3DERR_INVALIDCALL (0x8876086C) or E_FAIL (0x80004005) on error
Texture Object Layout (0x74 bytes, vtable 0x4DA648)
+0x00: void** vtable (PTR_FUN_004DA648)
+0x04: IDirect3DTexture8* texture (primary)
+0x08: IDirect3DTexture8* texture_mip1 (first mip level)
+0x0C: Graphics* graphics (back-pointer)
+0x18: char* filename (loaded from)
+0x1C: uint8 flag_1
+0x1D: uint8 flag_2
+0x1E: uint8 flag_3
+0x20: uint32 width
+0x24: uint32 height
+0x28: uint32 mip_levels
+0x2C: uint32 format (D3DFMT_*)
+0x30: uint32 refcount (incremented on cache hit)
+0x34: uint32 pool (D3DPOOL_*)
+0x38-0x70: internal D3D data
Graphics Object Texture Cache
- Graphics+0x2E4: AthenaList* texture_cache (list of loaded textures)
- Graphics+0x6F0: void** texture_array (pointer to cache data)
- Graphics+0x2E8: int texture_count
Mipmap Support
The game uses explicit mipmap files:
texture.png— base level (mip0)texture-mip1.png— first mip leveltexture-mip2.png— second mip level- Debug strings confirm: "Create Texture with %d Mip Levels...", "Plotting mip level..."
Supported Formats
- PNG: Primary format for most textures (119 references)
- BMP: Used for checker patterns (purplechecker.bmp, brightgreenchecker.bmp, etc.)
- JPG: Supported (JPEG error strings in binary)
Built-in Libraries (Statically Linked)
- libpng: "Incompatible libpng version...", "Width too large..."
- libjpeg: "Invalid SOS parameters...", "Corrupt JPEG data..."
- D3DX8: "D3DXERR_INVALIDMESH", "D3DXERR_CANNOTATTRSORT", etc.
.MESH Binary Model Format (v1-v5, CONFIRMED)
Version 1 (Sphere, 8Ball, Hamster, etc.)
[4] version (=1)
[4] name_length
[name_length] name string
[88] material data: ambient(4f), diffuse(4f), specular(4f), shine, shadow_bias, shadow_scale
[4] texture_name_length
[texture_name_length] texture filename
[4] vertex_count
[4] unknown (material group index?)
[vertex_count * 32] vertex array:
[float x, y, z] [float nx, ny, nz] [float u, v] = 32 bytes per vertex
[remaining] face/triangle data
Versions 2-5 (Chomper, FanBody, HammyJudge, etc.)
Same header structure but with additional sub-object sections. Texture string can be found by scanning for ".png"/".jpg" length-prefixed strings. Vertex data may be split into multiple mesh buffers with separate headers.
Parser
- Code:
reimpl/src/level/mesh_parser.c, header:reimpl/include/level/mesh_parser.h - Test:
reimpl/src/level/mesh_test.c - Successfully parses: Sphere (59 verts), Hamster (119 verts), 8Ball (42 verts), FunBall, Eye, Sawblade, TarBubble, etc.
- v1 fully working, v2-v5 partially working (finds texture, may miss vertices in sub-objects)
Binary MESHWORLD Format (from Ghidra Decompilation of 0x4629E0)
Complete Section Layout
Section 1: Materials
[4] material_count
For each material:
[4] name_length + [name_length] name
[4] face_start, [4] face_count, [4x4] unknown (6 uint32 total)
[4] extended_flag (checked as char, 4 bytes read)
If extended:
[4x4] ambient RGBA, [4x4] diffuse RGBA, [4x4] specular RGBA
[4] shine, [4] has_reflective, [4] has_texture
If has_texture: [4] tex_name_length + [tex_name_length] texture filename
Section 2: Mesh Buffers
[4] mesh_buffer_count
For each buffer:
[4] name_length + [name_length] name
[4] face_count
For each face: [3 reads of 4 bytes] + FUN_004685e0 call
Section 3: Game Objects
[4] object_count
For each object:
[4] object_type (0=straight, others=special)
If type==0: [3x4] position, [3x4] size, [3x4] scale, material init
Section 4: Bounding Box
[6x4] min_x, min_y, min_z, max_x, max_y, max_z
Section 5: Vertex Array
[4] vertex_count
[vertex_count * 32] vertex data (pos+norm+uv, 32 bytes each)
Section 6: Post-load
level+0x43C = 0 (vertex_data_ready)
level+0x47C = this (self_ptr)
Timer_Init, vtable callback, __close
Level Object Offsets (LevelState/MeshWorld)
| Offset | Field | Description |
|---|---|---|
| +0x438 | vertex_count | Number of vertices |
| +0x440 | vertex_array | Vertex data (32 bytes each) |
| +0x454 | min_x | Bounding box min X |
| +0x458 | min_y | Bounding box min Y |
| +0x45C | min_z | Bounding box min Z |
| +0x468 | max_x | Bounding box max X |
| +0x46C | max_y | Bounding box max Y |
| +0x470 | max_z | Bounding box max Z |
| +0x894 | materials | Material list (AthenaList) |
| +0xCAC | mesh_buffers | Mesh buffer list (AthenaList) |
Ball Physics Constants (from Ball_Update dispatcher 0x405100)
| Value | Usage |
|---|---|
| radius = 35.0 (0x420C0000) | Ball collision radius |
| max_speed = 600.0 (0x44160000) | Maximum velocity |
| max_speed2 = 1200.0 (0x44960000) | Maximum velocity (secondary) |
| friction1 = 0.5 (0x3F000000) | Surface friction |
| friction2 = 0.2 (0x3E4CCCCD) | Low friction |
| damping = 0.2 (0x3E4CCCCD) | Velocity damping |
Ball ApplyForce Multipliers
| Condition | Multiplier | Address |
|---|---|---|
| First frame | 1.0 → 0.25 | 0x4CF380 |
| In tube | 0.0 (no force!) | 0x4CF378 |
| On ice | 0.2 | 0x4CF374 |
| Is dizzy | 0.75 | 0x4CF36C |
Level Vtable (0x4D8FB0) — 16 Entries
| Index | Offset | Address | Function |
|---|---|---|---|
| 0 | +0x00 | 0x4629C0 | Level_dtor |
| 1 | +0x04 | 0x4606D0 | Level_Update (233 lines) |
| 2 | +0x08 | 0x472770 | Level_vtbl2 |
| 3 | +0x0C | 0x471750 | Level_LoadMesh |
| 4 | +0x10 | 0x470440 | Level_vtbl4 |
| 5 | +0x14 | 0x46F3B0 | Level_vtbl5 |
| 6 | +0x18 | 0x470150 | Level_vtbl6 |
| 7 | +0x1C | 0x46F390 | Level_vtbl7 |
| 8 | +0x20 | 0x471830 | Level_DrawBuffer |
| 9 | +0x24 | 0x45DFD0 | Level_vtbl9 |
| 10 | +0x28 | 0x461370 | Level_vtbl10 |
| 11 | +0x2C | 0x44ACB0 | Level_vtbl11 |
| 12 | +0x30 | 0x45DE30 | Level_LoadMeshWorld |
| 13 | +0x34 | 0x461890 | Level_vtbl13 |
| 14 | +0x38 | 0x4629E0 | Level_BinaryLoader |
| 15 | +0x3C | 0x460DA0 | Level_vtbl15 |
Level Viewer v2
Build & Run
cd ~/hamsterball-re/reimpl
gcc -o build/level_viewer_v2 \
src/level/mesh_parser.c src/level/level_viewer_v2.c \
-I include -lm \
$(pkg-config --cflags sdl2 SDL2_image gl glew) \
$(pkg-config --libs sdl2 SDL2_image gl glew) -lGLU
# Run with a level:
DISPLAY=:99 ./build/level_viewer_v2 ~/hamsterball-re/originals/installed/extracted/Levels/Level1.MESHWORLD
# Controls: WASD=move, mouse=look, Space=up, Shift=down, L=wireframe, T=textures, ESC=quit
Features
- Loads .MESHWORLD files, scans for all object types (START, FLAG, SAFESPOT, PLATFORM, BUMPER, etc.)
- Renders with OpenGL: grid, axes, start positions (green crosshairs), flag markers (yellow), other objects (blue)
- Loads .MESH models for ball (Sphere.MESH) rendering at start position
- Camera centered on level bounding box
.MESH Parser Test
cd ~/hamsterball-re/reimpl
gcc -o build/mesh_test src/level/mesh_parser.c src/level/mesh_test.c -I include -lm
./build/mesh_test ~/hamsterball-re/originals/installed/extracted/Meshes/Sphere.MESH
# Output: Name: Sphere01, Vertices: 59, Texture: HamsterBall.png, BBox: X[-4.88,25.00]...
.MESH Python Renderer (Visual Identification)
Located at ~/hamsterball-re/tools/render_meshes.py. Uses numpy + matplotlib to render .MESH files as PNG images for visual identification.
Workflow:
- Parse .MESH binary format (v1 working, v2-v5 partial)
- Render multi-view (3 angles: 30°, 120°, 210°) with face data + scatter overlay
- Save to
~/hamsterball-re/analysis/screenshots/meshes/{name}.png - Vision-analyze the rendered images to identify game objects
- Use identified names for Ghidra function/structure renaming
Usage:
source ~/hb_venv/bin/activate
python ~/hamsterball-re/tools/render_meshes.py
Current Issues:
- v2-v5 meshes (HammyJudge, DawgShoe, MagnifyingGlass, SkyPillar) parse with 0 vertices — sub-object format not yet handled
- Face/index data not extracted — only vertex scatter rendering for v1 meshes
- NaN errors on some meshes (Bell, FanBody, GlassBonus-Smashed) — need bounds checking
- The v1 format after texture name: vertex_count (uint32), unknown (uint32), then vertices (32 bytes each)
v1 Format Details (confirmed from Sphere.MESH hex analysis):
offset 0: version (1)
offset 4: name_length (9)
offset 8: name ("Sphere01\0")
offset 17: material data (88 bytes):
- ambient RGBA (4 floats)
- diffuse RGBA (4 floats)
- specular RGBA (4 floats)
- shine (1 float, e.g. 25.0)
- shadow_bias (1 float, 0.0)
- shadow_scale (1 float, stored as int 1)
offset 105: texture_name_length (16)
offset 109: texture name ("HamsterBall.png\0")
offset 125: after texture, two uint32s:
- 0x3B = 59 (vertex_count!)
- then vertex data starts (32 bytes each: xyz + normals + uv)
v2+ Format (needs more RE — from Chomper.MESH hex analysis):
- Version 2+: sub-objects with separate mesh buffers
- After name: position(3 floats), then material(88 bytes)
- Sub-objects: count, name+texture per sub-object, transform data per sub-object
- Each sub-object has its own vertex/face data section
- Need to match decompiled FormatReader (0x4629E0) logic for full parsing
C Reimplementation (Milestone 6 — In Progress)
Module Structure (~/hamsterball-re/reimpl/src/)
main.c— Game loop: SDL init, load level, physics tick, render, input pollingphysics.c/physics.h— Ball physics: gravity, acceleration, collision planes, position/velocityinput.c/input.h— Keyboard polling: up/down/left/right/action1/action2renderer.c/renderer.h— OpenGL render stub (D3D8→GL translation layer)audio.c/audio.h— Audio stub (BASS→SDL_mixer)level.c/level.h— Level management stubconfig.c/config.h— Config management, types (Vec3, App, GameObject)ui.c/ui.h— UI stub
Build System
CMake-based. Key fix: SDL2 must be found via pkg-config, not FindSDL2.cmake:
find_package(PkgConfig REQUIRED)
pkg_check_modules(SDL2 REQUIRED sdl2)
target_include_directories(hamsterball PRIVATE ${SDL2_INCLUDE_DIRS})
target_link_directories(hamsterball PRIVATE ${SDL2_LIBRARY_DIRS})
target_link_libraries(hamsterball ${SDL2_LIBRARIES} GL m pthread)
Type Conflict Fix
config.h and game.h both defined bool, Vec3, App, GameObject — resolved by using unique
type names (GameApp, GameVec3, etc.) or #include guards with #ifndef checks.
Current Status
- Builds and runs successfully on Linux (SDL2 + OpenGL)
- Loads MESHWORLD levels (57+ levels parse OK)
- Game loop runs: init → load → update → render → shutdown (clean exit)
- Input: keyboard polling works, 6 game keys mapped
- Physics: gravity + acceleration + velocity applied per frame, collision planes checked
- Rendering: stub — needs actual mesh rendering pipeline (GL draw calls from MeshWorld data)
- Audio: stub — needs BASS→SDL_mixer bridge
D3D8/DInput8/DSound8 Cross-Compilation (32-bit Windows)
The reimplementation uses the original Windows APIs, not SDL2+OpenGL. This allows
faithful recreation and Wine compatibility on Linux.
Toolchain Requirements:
i686-w64-mingw32-gcc(32-bit MinGW cross-compiler)- D3D8 headers:
/usr/share/mingw-w64/include/d3d8.h(standard MinGW install) - DInput8 headers:
/usr/share/mingw-w64/include/dinput.h - DSound8 headers:
/usr/share/mingw-w64/include/dsound.h
Critical Fixes for Compilation (FIXED 2026-04-15):
-
WAVEFORMATEX requires mmsystem.h — Include before dsound.h:
#include <windows.h> #include <mmsystem.h> /* Needed before dsound.h for WAVEFORMATEX */ #include <d3d8.h> #include <dinput.h> #include <dsound.h> -
D3DPRESENT_PARAMETERS field names — Use correct field names:
PresentationIntervaldoes NOT exist in D3D8 headers- Use
FullScreen_PresentationIntervalinstead:
g_d3dpp.FullScreen_PresentationInterval = D3DPRESENT_INTERVAL_DEFAULT; -
D3D8 has no D3DX math functions — D3DXVECTOR3, D3DXMATRIX, D3DX_PI are in D3DX8,
not D3D8. Use plain math:- Replace
D3DXVECTOR3withstruct { float x,y,z; }or floats - Replace
D3DX_PIwith3.14159265f - Build view matrices manually (see win32_main.c SetMatrices function)
- Replace
-
No D3DXCreateTextureFromFile — D3D8 doesn't have this. Use D3D8 API directly:
IDirect3DTexture8creation via device methods- Or link d3dx8.lib if using D3DX (separate DLL)
Working Compilation Command:
i686-w64-mingw32-gcc -std=c11 -m32 -O2 -g -Wall \
-Iinclude -DWIN32_LEAN_AND_MEAN \
src/core/win32_main.c src/level/meshworld_parser.c src/level/mesh_parser.c \
-o hamsterball.exe \
-ld3d8 -ldinput8 -ldsound -ldxguid -lole32 -lwinmm -mwindows
Link Libraries:
-ld3d8— Direct3D8 API-ldinput8— DirectInput8 API-ldsound— DirectSound8 API-ldxguid— DirectInput/DirectSound GUIDs-lole32— COM initialization-lwinmm— Windows Multimedia (for timer functions)-mwindows— Windows GUI mode (no console)
Testing with Wine:
# On Linux, Wine translates D3D8 → OpenGL automatically
wine hamsterball.exe
Important: The original game ships bass.dll alongside the exe. For the open-source
reimplementation, BASS.dll can be:
- Purchased separately and placed alongside the exe (eventually)
- Replaced with SDL_mixer or similar (interim, not faithful to original API)
The current approach uses D3D8/DInput8/DSound8 for authenticity, BASS can be stubbed
for now until licensing is resolved.
SDL2 headers use #include <SDL2/SDL.h> but SDL_image/mixer headers include <SDL.h>
internally. You need BOTH -I (for SDL2/SDL.h style) AND -isystem (for bare SDL.h
style from within SDL2_image/mixer headers):
cd ~/hamsterball-re/reimpl
bash build/build_win64.sh # Uses the saved build script
The build script is at reimpl/build/build_win64.sh. Key flags:
-Ipaths forSDL2/SDL.h,SDL2/SDL_image.h,SDL2/SDL_mixer.h-isystempaths for bareSDL.h(included from within SDL2_image/mixer)- Links:
-lopengl32 -lglu32 -lsetupapi -lole32 -loleaut32 -limm32 -lversion -lwinmm - Output:
build-win64/hamsterball.exe(~15MB static)
IMPORTANT: Use opendir() instead of fopen() for directory existence checks.
On Windows/Wine, fopen("path/to/dir", "rb") returns NULL even if the directory exists.
opendir("path/to/dir") works correctly on both Linux and Windows.
Run (Linux)
cd ~/hamsterball-re/reimpl/build
./hamsterball # Runs with default level, 3-sec timeout in headless mode
Run (Wine — test Windows build)
cd ~/hamsterball-re/reimpl/build-win64
DISPLAY=:99 WINEDEBUG=-all timeout 10 wine hamsterball.exe 2>/dev/null
# Copies of game assets (Levels/, Textures/, etc.) must be alongside hamsterball.exe
Legacy Level Viewer (still works)
cd ~/hamsterball-re/reimpl
gcc -o build/level_viewer \
src/level/meshworld_parser.c src/level/level_viewer.c \
-I include \
$(pkg-config --cflags sdl2 SDL2_image SDL2_mixer gl glew) \
$(pkg-config --libs sdl2 SDL2_image SDL2_mixer gl glew) -lm
🔗 Related Documents
rsksJIT
rsksJIT — Universal Ref Loader (Logging Build)
Based on RodentRacer's v3 JIT mesh injection mod (mods/universal-ref-loader/).
This is a separate project that adds comprehensive logging without modifying the original.
What It Does
A bass.dll proxy that hooks Scene_CreateDynamicObjects at 0x0040C4BA and logs
everything the universal ref loader does:
- Every ref name seen by the factory dispatch
- Every board slot check (offset, value before/after JIT injection)
- Every JIT mesh load (path, cache hit/miss, success/fail)
- Every clone operation (Level_CloneTree)
- Every difficulty bypass (save/restore App+0x23C)
- Every safety check pass/fail
- Board pointer, vtable pointer, App pointer values
Log Output
Log file: Z:\tmp\ref_loader_log.txt (maps to C:\tmp\ref_loader_log.txt on Windows,
or /tmp/ref_loader_log.txt on Wine/Linux).
Usage
- Copy
bass.dllinto the Hamsterball game directory (backup original first!) - Ensure
bass_real.dllis the true original BASS library (89710 bytes), NOT
another proxy DLL - Launch the game
- Navigate to a race level
- Read
C:\tmp\ref_loader_log.txt
Build
i686-w64-mingw32-gcc -shared -o bass.dll jit_log_mod.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
BASS Proxy Notes
The original game imports 20 BASS functions. The v3 original proxy only forwarded 15.
This build adds 5 missing exports that the game's import table requires:
BASS_Start— Start audio outputBASS_Stop— Stop audio outputBASS_MusicPlayEx— Play music module (extended)BASS_ErrorGetCode— Get last error codeBASS_ChannelSetAttributes— Note: plural form (game imports this, not singularBASS_ChannelSetAttribute)
Without all 20 exports, Wine aborts with "unimplemented function BASS.dll.BASS_Start".
Testing on Wine/llvmpipe
The game renders black on Wine with llvmpipe software rendering. Key navigation
via xdotool does not reliably work when the screen is black. This mod should be tested
on real Windows or with a GPU-accelerated Wine setup.
The hbtestd MCP tool can crash-test the DLL (verifies it loads without crashing),
but cannot navigate to a race level on llvmpipe due to the black screen issue.
Original Project
This is based on mods/universal-ref-loader/ by RodentRacer. The original project
should NOT be modified — this is a separate fork for logging/analysis purposes.
🔗 Related Documents
ArenaBoard Arena System
ArenaBoard Arena System
The multiplayer arena battle mode in Hamsterball. Players compete on floating
platforms ("boards") with hazards, trying to knock each other off or survive
the longest.
Architecture Overview
ArenaBoard (base class)
├── ArenaBoard_WarmUp_ctor → Warm-up Arena (ID 1)
├── ArenaBoard_Beginner_ctor → Beginner Arena (ID 2)
├── ArenaBoard_Intermediate_ctor → Intermediate Arena (ID 3)
├── ArenaBoard_Dizzy_ctor → Dizzy Arena (ID 4)
├── ArenaBoard_Tower_ctor → Tower Arena (ID 5)
├── ArenaBoard_Up_ctor → Up Arena (ID 6)
├── ArenaBoard_Neon_ctor → Neon Arena (ID 7)
├── ArenaBoard_Expert_ctor → Expert Arena (ID 8)
├── ArenaBoard_Odd_ctor → Odd Arena (ID 9)
├── ArenaBoard_Toob_ctor → Toob Arena (ID 10)
├── ArenaBoard_Wobbly_ctor → Wobbly Arena (ID 11)
├── BoardLevel_Glass_ctor → Glass Arena (ID 12)
├── ArenaBoard_Sky_ctor → Sky Arena (ID 13)
├── ArenaBoard_Master_ctor → Master Arena (ID 14)
├── ArenaBoard_Impossible_ctor → Impossible Arena (ID 15)
└── CollSlices variants (collision mesh slicing)
Tournament Race Order (from Tournament_AdvanceRace 0x427080)
The 15-race tournament sequence, revealed by the switch statement:
| Race | Ctor | Level Name | Size (bytes) | Asset Path |
|---|---|---|---|---|
| 1 | LevelBoard_WarmUp_ctor | Warm Up | 0x436C | — |
| 2 | LevelBoard_Beginner_ctor | Beginner | 0x644C | — |
| 3 | LevelBoard_Intermediate_ctor | Intermediate | 0x438C | — |
| 4 | LevelBoard_Dizzy_ctor | Dizzy | 0x4BE0 | — |
| 5 | LevelBoard_Tower_ctor | Tower | 0x5418 | — |
| 6 | LevelBoard_Up_ctor | Up | 0x4790 | — |
| 7 | Board_NeonRace_ctor | Neon Race | 0x4394 | — |
| 8 | LevelBoard_Expert_ctor | Expert | 0x4FD8 | — |
| 9 | LevelBoard_Odd_ctor | Odd | 0x43B0 | — |
| 10 | LevelBoard_Toob_ctor | Toob | 0x646C | — |
| 11 | LevelBoard_Wobbly_ctor | Wobbly | 0x4388 | — |
| 12 | Board_Glass_ctor | Glass | 0x4390 | — |
| 13 | LevelBoard_Sky_ctor | Sky | 0x47F8 | — |
| 14 | BoardLevel_Master_Ctor | Master | 0x6498 | — |
| 15 | Board_Impossible_ctor | Impossible | 0x4380 | — |
ArenaBoard Arena Asset Paths
Each arena loads a MeshWorld and CollisionLevel pair from these paths:
| Arena Init Function | Arena Mesh Path | Extra Level |
|---|---|---|
| ArenaBoard_Beginner (0x22550) | "ArenaBoard (Beginner Arena)" | Race: "Beginner Race" (internal: Cascade Race) |
| ArenaLevel_Intermediate_Init (0x414180) | levels\arena-intermediate | — |
| ArenaLevel_Dizzy_Init (0x414240) | levels\arena-dizzy | Levels\Level3-Swirl |
| ArenaLevel_Expert_Init (0x414B10) | levels\arena-expert | — |
| ArenaLevel_Neon_Init (0x416F40) | levels\arena-neon | 2x AthenaList transforms + boundary sphere |
| ArenaLevel_Glass_Init (0x417DF0) | levels\arena-Glass | — |
| ArenaLevel_Impossible_Init (0x418540) | levels\arena-impossible | — |
| ArenaLevel_Sky_Init (0x4158C0) | levels\arena-Sky | "PILLAR" object collection |
| ArenaLevel_Master_Init (0x416080) | levels\arena-Master | — |
| ArenaLevel_Toob_Init (0x414F00) | (in ctor) | — |
| ArenaLevel_Odd_Init (0x414CE0) | levels\arena-Odd | — |
| ArenaLevel_Toob_Init (0x414F00) | levels\arena-Toob | 5x "N:BUMPER%d" objects |
Special Arena Init Logic
ArenaLevel_Dizzy_Init: Loads TWO mesh worlds — the arena
(levels\arena-dizzy) and a bonus level (Levels\Level3-Swirl) at this+0x11F8.
ArenaLevel_Sky_Init: Iterates all objects in the arena MeshWorld looking
for "PILLAR" name prefix (case-insensitive via __strnicmp). Adds matching
objects to a pillar list at this+0x11FB.
ArenaLevel_Toob_Init: Collects 5 bumper objects named "N:BUMPER1" through
"N:BUMPER5" via Scene_CollectByNameFilter. Each bumper stored at this+0x11F8
(stride 0x106). Bumper count array at this+0x1716.
ArenaLevel_Neon_Init: Most complex init — loads arena, then applies
Matrix transformations to all objects in two AthenaLists (first at
this+0xA75, second at this+0xB7B). Each object gets 3 matrix transforms
applied to its position/orientation, with a "visible" check based on whether
the object's float value differs from DAT_004CF3C8. Also creates a SceneObject
with 10x10 scale and 400.0 radius for the arena boundary.
ArenaBoard Timer System
Timer Structure (0x14 bytes)
| Offset | Type | Field | Description |
|---|---|---|---|
| +0x00 | vtable* | vtable | Timer vtable (0x4D8E74) |
| +0x04 | byte | active | Timer is running |
| +0x08 | int32 | period | Total ticks before expiry (default: 100) |
| +0x0C | int32 | count | Current tick count |
| +0x10 | byte | expired | Set to 1 when count >= period |
ToggleTimer_Init (0x458E60)
ToggleTimer_Init(Timer *t) {
t->vtable = &Timer_Vtable;
t->active = 0;
t->period = 100;
t->count = 0;
}
ToggleTimer_Tick (0x458E90)
ToggleTimer_Tick(Timer *t) {
t->expired = 0;
t->count++;
if (t->count >= t->period) {
t->count = 0;
t->active = !t->active; // Toggle on/off
t->expired = 1; // Signal expiry
}
}
ToggleTimer_Cleanup (0x458E80)
ToggleTimer_Cleanup(Timer *t) {
t->vtable = &Timer_Vtable; // Reset to default (deactivate)
}
ArenaBoard_TickDown (0x472A50)
Decrements the tick counter at this+0x21F*4 (object lifetime counter).
When it reaches < 1, calls vtable[0x4C] (cleanup) and vtable[0x40] (destructor).
ArenaBoard Object System
ArenaSceneObj_Tick (0x42B660)
Per-frame update for arena objects (hammers, saws, etc.):
ArenaSceneObj_Tick(Object *obj) {
// Animate vertical position (float up)
obj->float_offset += DAT_004cf524; // small upward velocity
if (obj->float_offset > DAT_004cf438) // max float height
obj->float_offset = 0.75f; // clamp
// Animate cooldown timer (count down)
obj->cooldown -= DAT_004cf370; // decrement
if (obj->cooldown < DAT_004cf368) // zero threshold
obj->cooldown = 0;
// Tick two embedded timers (offset 0x888 and 0x89C)
ToggleTimer_Tick(obj + 0x888);
ToggleTimer_Tick(obj + 0x89C);
}
Object offsets:
+0x880: float position (vertical bob)+0x884: cooldown timer (float)+0x888: Timer struct (0x14 bytes) — primary animation timer+0x89C: Timer struct (0x14 bytes) — secondary animation timer
ArenaBoard Render System (0x421910)
HUD Layout (4-player split screen)
+--------+--------+ Screen: 800x600
| P1 | P2 | Quadrants: 400x300 each
| 75,75 | 725,75 | Timer font at app->timer_font (offset 0x318/0x328)
+--------+--------+ Shadow font at app->shadow_font (offset 0x318)
| P3 | P4 |
| 75,525 | 725,525|
+--------+--------+
Timer Display
- Main timer: centered at
screen_center - 88, drawn with large font - Sub-seconds:
".N"format (tenths of a second) - Flashing red when time < 1100 ticks (with expired flag)
- Flashing blue when time < 600 ticks
- "TIE BREAKER!" text at (400, 40) when both
timer_startedandtie_breakerflags set
Player Visibility Flags (in App struct)
| Offset | Flag | Meaning |
|---|---|---|
| +0x677 | p2_hidden | Hide P2 quadrant |
| +0x717 | p3_hidden | Hide P3 quadrant |
| +0x7B7 | p4_hidden | Hide P4 quadrant |
Menu Command System (from GameSelectionManager 0x433AC0)
The title screen dispatches menu commands as string comparisons:
| Command | Description | Menu Created |
|---|---|---|
| "BACK" | Return to title | MainMenu_ctor |
| "LOCKED" | Mirror locked dialog | OkayDialog ("NOT UNLOCKED!") |
| "1PT" | 1-Player Tournament | DifficultyMenu or TourneyContinueDialog |
| "1PMT" | 1-Player Mirror Tournament | DifficultyMenu or TourneyContinueDialog |
| "1PP" | 1-Player Practice | PracticeMenu_ctor |
| "1PTT" | 1-Player Time Trial | TimeTrialMenu_ctor |
| "PARTY" | Multiplayer Party | MPMenu_ctor |
Mirror Tournament Unlock
The mirror tournament requires winning a regular tournament at Normal or
Frenzied difficulty. The unlock check message:
"THE MIRROR TOURNAMENT ISN'T UNLOCKED YET! TO UNLOCK THE MIRROR TOURNAMENT,
YOU NEED TO WIN A TOURNAMENT AT NORMAL OR FRENZIED DIFFICULTY!"
Tournament Save System
- Save file:
DATA\TOURNAMENT.SAV - Uses
_check_file_access()to detect existing save - If save exists and no game in progress: shows TourneyContinueDialog ("Resume?")
- If no save or new game: shows DifficultyMenu
App Tournament Flags (App struct offsets)
| Offset | Flag | Meaning |
|---|---|---|
| +0x234 | (set 0) | Clear on menu entry |
| +0x235 | is_tournament | 1=tournament mode, 0=practice/time trial |
| +0x236 | is_mirror | 1=mirror tracks (reverse direction) |
| +0x237 | tournament_complete | 1=all 15 races done |
| +0x5D4 | player_count_flag | Set to 1 for single player |
| +0x5D5 | (cleared) | Cleared on tournament start |
| +0x5D7 | (cleared) | Cleared on tournament start |
Tournament Time Bonus System
Time Award Per Difficulty (from Tournament_AdvanceRace)
| Difficulty | Base Bonus | Increment |
|---|---|---|
| Normal (0) | +1000 ms | DAT_004CF6F4 (per-race increment) |
| Frenzied (1) | +500 ms | DAT_004CF3D8 (smaller per-race increment) |
Player Time Tracking
Each player slot (stride 0xA0, up to 4 players at App+0x5E8):
| Player | Current Time | Extra Time | Race Index | Level Name | Active |
|---|---|---|---|---|---|
| P1 | App+0x5E8 | App+0x5EC | App+0x60C | App+0x610 | App+0x5D8 |
| P2 | App+0x688 | App+0x68C | App+0x6CC | App+0x6D0 | App+0x678 |
| P3 | App+0x728 | App+0x72C | App+0x76C | App+0x770 | App+0x718 |
| P4 | App+0x7C8 | App+0x7CC | App+0x80C | App+0x810 | App+0x7B8 |
For races 1-2: current_time = base_time, extra_time = 0
For races 3+: extra_time = base_time (adds to existing accumulated time)
Tournament Object Structure (Tournament_AdvanceRace this)
| Offset | Type | Field |
|---|---|---|
| +0x04 | int* | App pointer (scene state) |
| +0x08 | int | current_race_index (1-15) |
| +0x0C | void* | current_board_object |
| +0x10 | byte | skip_bonus_flag |
| +0x11 | byte | no_time_flag |
| +0x14 | float[N] | race_times_array (time at start of each race) |
| +0x50 | int[N] | race_times_ms_array (millisecond timestamps) |
Related Functions
| Address | Name | Purpose |
|---|---|---|
| 0x427080 | Tournament_AdvanceRace | Create next race in tournament |
| 0x433AC0 | GameSelectionManager | Title screen command dispatcher |
| 0x422550 | ArenaBoard_Beginner_ctor | Beginner arena constructor |
| 0x4226E0 | ArenaBoard_Intermediate_ctor | Intermediate arena ctor |
| 0x422790 | ArenaBoard_Dizzy_ctor | Dizzy arena ctor |
| 0x423060 | ArenaBoard_Expert_ctor | Expert arena ctor |
| 0x424860 | ArenaBoard_Neon_ctor | Neon arena ctor |
| 0x423BF0 | ArenaBoard_Sky_ctor | Sky arena ctor |
| 0x4234E0 | ArenaBoard_Toob_ctor | Toob arena ctor |
| 0x423220 | ArenaBoard_Odd_ctor | Odd arena ctor |
| 0x424EC0 | ArenaBoard_Impossible_ctor | Impossible arena ctor |
| 0x421910 | ArenaBoard_Render | HUD/timer rendering |
| 0x42B660 | ArenaSceneObj_Tick | Arena hazard animation |
| 0x458E60 | ToggleTimer_Init | Timer struct initializer |
| 0x458E90 | ToggleTimer_Tick | Timer tick + toggle logic |
| 0x458E80 | ToggleTimer_Cleanup | Timer deactivation |
| 0x472A50 | ArenaBoard_TickDown | Lifetime countdown |
| 0x4288B0 | App_StartTournamentRace | Tournament launch entry |
| 0x445230 | Scene_StartTournament | Scene tournament setup |
| 0x446730 | Tourney_SaveTournament | Save tournament state |
| 0x42E060 | GameSelectionScreen_ctor | Tournament results screen |
🔗 Related Documents
Runtime Environment
Hamsterball - Runtime Environment
System Requirements (from original game)
- OS: Windows 2000/XP/Vista/7
- DirectX: Version 8 or higher required
- CPU: Pentium II or equivalent
- RAM: 64MB minimum
- Video: DirectX 8 compatible 3D accelerator
- Sound: DirectX compatible sound card
Registry Keys
The game uses ADVAPI32.dll functions:
RegOpenKeyExA,RegOpenKeyA- Open registry keysRegCreateKeyA- Create registry keysRegQueryValueExA- Read valuesRegSetValueExA- Write valuesRegCloseKey- Close keys
Likely Registry Locations (CONFIRMED from eSellerate API):
HKLM\SOFTWARE\Raptisoft\Hamsterball- Game installation pathHKLM\SOFTWARE\Microsoft\DirectX- DirectX version check- eSellerate DRM keys for activation
File Paths Used
From binary string analysis:
DATA\HS.CFG- High scores and configuration save file (CONFIRMED)Levels\*.MESHWORLD- Level data files (CONFIRMED)Meshes\*.MESH- 3D model files (CONFIRMED)Textures\*.png,Textures\*.bmp- Texture files (CONFIRMED)Sounds\*.ogg- Sound effects (CONFIRMED)Music\Music.mo3- Background music (CONFIRMED)Fonts\*\font.description- Font metric data (CONFIRMED)Fonts\*\Data0.png- Font texture atlases (CONFIRMED)%s.mesh- Format string for mesh loading%s.meshcollision- Format string for collision mesh loading%s.meshworld- Format string for level loading%s.ogg,%s.wav- Format strings for sound loading%s\data%d.png- Format string for mipmapped texture loading
Configuration Files:
Jukebox.xml- Music mapping (CONFIRMED)RaceData.xml- Race timing/scoring parameters (CONFIRMED)DATA\HS.CFG- Save data with player names and high scores (CONFIRMED)DATA\TOURNAMENT.SAV- Tournament progress (CONFIRMED)
DLL Dependencies
Required at Runtime:
| DLL | Purpose | Notes |
|---|---|---|
| d3d8.dll | DirectX 8 3D rendering | Primary graphics API |
| DINPUT8.dll | DirectInput 8 | Input handling (keyboard/gamepad) |
| DSOUND.dll | DirectSound | Audio output via DirectSound |
| BASS.dll | BASS audio library | Music playback (MO3 format) |
| KERNEL32.dll | Windows core API | File I/O, memory, threads |
| USER32.dll | Windows user interface | Window creation, message loop |
| GDI32.dll | Graphics Device Interface | Text rendering |
| ADVAPI32.dll | Windows registry | Settings storage |
| SHELL32.dll | Windows Shell | ShellExecute for URLs |
| ole32.dll | COM | COM initialization |
| WS2_32.dll | Winsock | Network for eSellerate |
| VERSION.dll | Version checking | DLL version queries |
Optional/Conditional:
| DLL | Purpose | Notes |
|---|---|---|
| d3d9.dll | DirectX 9 fallback | Referenced in strings, may be D3D8->D3D9 compat |
| d3d8d.dll | DirectX 8 debug | Debug version checking |
| eSellerateEngine.dll | DRM/activation | Third-party licensing |
| RICHED32.DLL | Rich text | Possibly for license dialogs |
| COMCTL32.dll | Common controls | UI elements |
DirectX 8 Interfaces Used
From import analysis:
-
Direct3DCreate8- Main D3D8 entry point (CONFIRMED)- Creates IDirect3D8 interface
- Used to create IDirect3DDevice8
- Game uses D3D8 for all 3D rendering
-
DirectSound (via DSOUND.dll ordinal import)
- Sound playback for effects
-
DirectInput8 (via DirectInput8Create)
- Keyboard and mouse input at minimum
- Possibly gamepad/joystick support
BASS Audio Library Usage
BASS_Init() - Initialize audio system
BASS_Start() - Start audio playback
BASS_Stop() - Stop all audio
BASS_Free() - Free audio resources
BASS_SetConfig() - Configure audio settings
BASS_ErrorGetCode() - Get last error
BASS_MusicLoad() - Load MO3 music module
BASS_MusicPlayEx() - Play music with position control
BASS_ChannelSetAttributes() - Set volume/pan/rate
BASS_ChannelStop() - Stop specific channel
Wine Execution Testing
Status: PARTIALLY WORKING
- Wine 9.0 initializes and launches the process
- No display server available for actual rendering
- Audio ALSA errors (no sound card in sandbox)
- Process stays running, indicating game loop starts
Next Steps for Wine Testing:
- Set up X virtual framebuffer (Xvfb)
- Install DirectX 8 runtime via winetricks
- Test with:
xvfb-run wine Hamsterball.exe - Consider using Wine with D3D shader converter (dxvk)
- Document any crashes or error dialogs
eSellerate DRM
The game contains eSellerate activation code:
- Purchase dialog strings present
- Network activation via eSellerate Engine
- Free play counter (shareware/trial version)
- "CLICK HERE TO BUY!" / "CLICK HERE TO REGISTER" strings
- For reimplementation: skip DRM entirely (we have permission)
Window Configuration
From WinMain analysis:
- Window class registered via RegisterClassExA / RegisterClassA
- Window created via CreateWindowExA
- Uses PeekMessageA for game loop (non-blocking message pump)
- SetCursor/ShowCursor for cursor management
- SetCapture/ReleaseCapture for mouse capture
- ClientToScreen/ScreenToClient for coordinate conversion
- Game window likely uses WS_EX_APPWINDOW style
🔗 Related Documents
SAFESWITCH Checkpoint System
SAFESWITCH Checkpoint System
The checkpoint/respawn-point selection system in Hamsterball. Allows level designers
to place named respawn markers along the track so the ball respawns at the last
checkpoint it passed, instead of going all the way back to START.
Overview
The system has two components placed in the level's MESHWORLD file:
-
SAFESPOT objects — invisible reference points placed at specific track positions.
They can carry a letter tag in parentheses (e.g.SAFESPOT(B)) or square brackets
(e.g.SAFESPOT[X]) to enable filtered respawn selection. -
E:SAFESWITCH(X) events — invisible collision trigger volumes placed along the
track. When the ball touches one, the letter parameter is copied into a filter
field on the ball, changing which SAFESPOTs are valid respawn candidates.
When the ball falls off the track and hits an E:LIMIT boundary, the respawn system
uses the current filter to find the nearest matching SAFESPOT — creating a
"last checkpoint" progression system.
Binary Implementation
E:SAFESWITCH Handler (DispatchCollisionEvents @ 0x40C5D0)
Address: 0x40C6FC in DispatchCollisionEvents
The handler performs a stricmp against "E:SAFESWITCH" (string at 0x4CF8A8).
If the event string matches, it executes the following logic:
// Pseudocode of the SAFESWITCH handler at 0x40C6FC
if (stricmp(event_string, "E:SAFESWITCH") == 0) {
char* paren = strchr(event_string, '('); // 0x4BACC0 = strchr, 0x28 = '('
if (paren == NULL) {
// No letter parameter — clear the filter
ball->c2c_filter[0] = '\0'; // ball+0xC2C = empty string
} else {
// Copy "(X)" from event string into ball+0xC2C
// (byte-by-byte copy until null terminator)
char* dst = ball + 0xC2C;
char* src = paren;
do {
*dst = *src;
dst++; src++;
} while (*src != '\0');
}
// FALL THROUGH to E:LIMIT handler (no return/jump past it)
// This means SAFESWITCH ALSO deactivates the ball:
ball->is_active = 0; // +0x768 = 0
ball->flag_2E9 = 1; // +0x2E9 = 1
// ... arena scoring logic if applicable ...
}
Key behavior: E:SAFESWITCH always falls through to the E:LIMIT handler. This means
touching a SAFESWITCH volume immediately deactivates the ball AND sets the checkpoint
filter — the ball doesn't just pass through and continue racing. The deactivation
triggers the respawn cycle, which uses the newly-set filter to select the respawn point.
Ball Filter Field
| Offset | Type | Size | Description |
|---|---|---|---|
ball+0xC2C |
char[] | ~8 bytes | SAFESWITCH filter string (e.g. "(B)", "(C)", or empty "") |
- Empty string — no SAFESWITCH has been hit yet (or a plain
E:SAFESWITCHwith no
letter was hit). The respawn search accepts any plain SAFESPOT (rejects[X]/[Z]tagged ones). - "(B)" — the ball hit an
E:SAFESWITCH(B)volume. The respawn search only accepts
SAFESPOTs whose name contains(B). - Set at construction to empty. Persists across respawns until changed by another SAFESWITCH.
Respawn Point Filter Logic (Ball_FindClosestRespawnPoint @ 0x405190)
The filter logic is in the respawn-point search loop (decompiled lines 121-144):
// For each SAFESPOT candidate in the board's respawn list:
// 1. Calculate filter string length
int filter_len = strlen(ball->c2c_filter); // ball+0xC2C
if (filter_len == 0) {
// NO FILTER — accept this SAFESPOT unless it has [Z] or [X] tags
bool accept = true;
if (strstr(safespot_name, "[Z]") != NULL) accept = false;
if (strstr(safespot_name, "[X]") != NULL) accept = false;
} else {
// FILTER ACTIVE — check if SAFESPOT name contains '('
char* paren = strchr(safespot_name, '(');
if (paren != NULL) {
// Compare first 2 chars: "(B" from SAFESPOT name vs "(B" from filter
if (strnicmp(paren, ball->c2c_filter, 2) == 0) {
// Match! Accept this SAFESPOT (subject to [Z]/[X] check below)
goto accept_safespot;
}
// No match — skip this SAFESPOT
}
// If no '(' in SAFESPOT name, skip it
}
// At accept_safespot label:
// Check [Z]/[X] tags (same as no-filter path)
// Then compute 3D distance from ball to SAFESPOT
// Track the closest matching SAFESPOT
The 2-character strnicmp comparison compares the first two characters after (:
"(B"fromSAFESPOT(B)vs"(B"fromball+0xC2C→ match (case-insensitive)"(B"fromSAFESPOT(B)vs"(C"fromball+0xC2C→ no match
This means the filter only checks the letter, not the closing paren. "(B)" in the
filter matches "(B)" in the SAFESPOT name, but the comparison is only on "(B".
Gravity-Mode Interaction (ball+0x748)
The respawn search has three modes based on ball+0x748 (gravity plane index):
| Value | Gravity | Search Behavior | Levels |
|---|---|---|---|
| 0 | Y-up (standard) | Iterates board's SAFESPOT list normally | Most races |
| 1 | X-axis (tilted) | Uses random selection from SAFESPOT list | Up Race (LevelUp) |
| 2 | Z-axis (flat) | Uses random selection from SAFESPOT list | odd race (Level6) |
In mode 0, the search iterates ALL SAFESPOTs and finds the closest by 3D distance.
In modes 1 and 2, the search picks a random SAFESPOT from the list (using RNG_Rand).
Multiplayer proximity check: In 2-player mode (App+0x234 flag), the search also
checks if the other player's ball is within ball+0x284 (ball radius) of the SAFESPOT.
If so, that SAFESPOT is rejected to prevent respawning on top of the other player.
Per-Level SAFESPOT/SAFESWITCH Inventory
Verified from binary strings extraction of each level's .MESHWORLD file:
| Race | Level File | SAFESPOTs | E:SAFESWITCH Triggers |
|---|---|---|---|
| 1 Warm-Up | Level1 | SAFESPOT (plain) | — |
| 2 Beginner | LevelCascade | SAFESPOT (plain) | — |
| 3 Intermediate | Level2 | SAFESPOT (plain) | — |
| 4 Dizzy | Level3 | SAFESPOT (plain) | — |
| 5 Tower | Level4 | SAFESPOT, (A), (B) | E:SAFESWITCH, (A), (B) |
| 6 Up | LevelUp | SAFESPOT (plain) | — |
| 7 Neon | LevelDark | SAFESPOT, (A) | E:SAFESWITCH, (A) |
| 8 Expert | Level5 | SAFESPOT, (A), (B), (C), (D), (E) | E:SAFESWITCH, (A), (B), (C), (D), (E) |
| 9 Odd | Level6 | SAFESPOT, (A), (B), (C), [X], [Z] | E:SAFESWITCH, (A), (B), (C) |
| 10 Toob | Level8 | SAFESPOT (plain) | — |
| 11 Wobbly | Level7 | SAFESPOT, (B), (C) | E:SAFESWITCH(B), E:SAFESWITCH(C) |
| 12 Glass | LevelGlass | SAFESPOT (plain) | — |
| 13 Sky | Level9 | SAFESPOT, (B), (C), (D), (E), (F), (G), (H) | E:SAFESWITCH(B) through (H) |
| 14 Master | Level10 | SAFESPOT, (A), (C), (D), (Q), (X), (Z) | E:SAFESWITCH, (A), (C), (D), (Q), (X), (Z) |
| 15 Impossible | LevelImpossible | SAFESPOT, (A), (B) | E:SAFESWITCH, (A), (B) |
Observations
- Early races (1-4, 6, 10, 12) have no checkpoint system — only plain
SAFESPOT.
The ball respawns at the nearest one regardless of progress. - Later races (5, 7-9, 11, 13-15) use lettered checkpoints to create progression.
The ball respawns at the last SAFESPOT whose letter matches the current filter. - Sky Race (Level9) has the most checkpoints: 7 lettered SAFESPOTs (B through H).
- Master Race (Level10) uses unusual letters: Q, X, Z — alongside A, C, D.
- odd race (Level6) is unique in having both
()and[]tagged SAFESPOTs:(A),(B),(C)— filtered by the SAFESWITCH letter system[X],[Z]— filtered by the gravity-mode system (ball+0x748), NOT by SAFESWITCH
Square Bracket Tags ([X], [Z])
Some SAFESPOTs use square brackets instead of parentheses. These are not related
to the SAFESWITCH system — they are filtered by the gravity-mode system:
SAFESPOT[X]— only used whenball+0x748 == 1(tilted X-axis gravity, Up Race)SAFESPOT[Z]— only used whenball+0x748 == 2(flat Z-axis gravity, odd race)
When ball+0x748 == 0 (standard Y-up gravity), SAFESPOTs with [X] or [Z] tags
are rejected during the search. This prevents a Y-up race from using a tilted-gravity
respawn point that might be positioned in an unexpected location.
Currently, only odd race (Level6) has both () and [] tagged SAFESPOTs in the
same level file, because it uses gravity mode 2 (Z-axis) which has a different search
behavior than the standard mode 0.
Complete Respawn Flow
When a ball falls off the track:
- E:LIMIT or E:SAFESWITCH collision →
ball+0x768 = 0(deactivate),ball+0x2E9 = 1 - Ball_FallUpdate (0x408830) countdown:
ball+0xC60 -= 0.02per frame - Timer expires (
ball+0xC60 < 0.0) →ball+0x2E8 = 1(needs_respawn) - Ball_FindClosestRespawnPoint (0x405190) runs:
- Resets ball state (render_scale=0, is_falling=1, invisible)
- Reads
ball+0x748to determine search mode (0=nearest, 1/2=random) - Reads
ball+0xC2Cto determine filter string - Iterates SAFESPOT list on
board+0x1518(AthenaList) - For each SAFESPOT: check filter match, check [X]/[Z] tags, compute distance
- Selects closest matching SAFESPOT (or random in modes 1/2)
- Teleports ball to selected SAFESPOT position
- Ball_Update (0x405E00) recovery:
render_scalegrows from 0.0 back to 1.0 - When
render_scale >= 1.0:is_falling = 0, ball is interactive again
Modding Implications
- To add checkpoints to a custom level: Place
SAFESPOT(X)reference points
andE:SAFESWITCH(X)collision volumes in the MESHWORLD file. The engine handles
the rest automatically. - To change which SAFESPOT a ball respawns at: Write a new filter string to
ball+0xC2C(e.g."(C)"). The next respawn will search forSAFESPOT(C). - To force respawn at START: Clear
ball+0xC2Cto empty string. The search
will find the nearest plain SAFESPOT or START point. - To disable checkpoint progression: Hook the SAFESWITCH handler at 0x40C6FC
and NOP the filter-copy loop, or always clearball+0xC2Cafter each respawn.
See Also
- Ball Respawn State Machine
— Fall timer and respawn trigger logic - Ball_FindClosestRespawnPoint decompilation
— Full search algorithm with filter logic - Collision Event Dispatch
— Where E:SAFESWITCH and E:LIMIT handlers live in DispatchCollisionEvents - level reference](LEVEL_REFERENCE.md)
— Per-level file and race mapping
🔗 Related Documents
Save & [[40558402976403|registry system]]
Save & Registry System
Tournament Save File
Path: DATA\TOURNAMENT.SAV
Format: Binary, raw struct dump (no header/magic)
Write Function: TourneyMenu_WriteSave (0x4264B0)
Read Function: TourneyMenu_LoadSaveAndShow (0x4265A0)
The save file is a simple sequential binary write of PlayerProfile fields + App state:
| Offset (file) | Size | Source (PlayerProfile) | Description |
|---|---|---|---|
| 0x00 | 4 | Profile+0x08 | current_race (1-14) |
| 0x04 | 4 | Profile+0x14 | (unknown int) |
| 0x08 | 0x3C | Profile+0x18 | race_time_array[15] (float × 15) |
| 0x44 | 0x3C | Profile+0x54 | race_time_array_2[15] (float × 15) |
| 0x80 | 4 | Profile+0x90 | accumulated_time_int |
| 0x84 | 1 | Profile+0x94 | difficulty_flag |
| 0x85 | 1 | Profile+0x95 | has_rollback_available |
| 0x86 | 1 | App+0x236 | is_mirror_mode |
| 0x87 | 4 | App+0x23C | race_active_flag |
| 0x8B | 4 | App+0x5E8 | total_time_float |
| 0x8F | 4 | App+0x5E4 | ranking_time_float |
| 0x93 | 4 | App+0x5F4 | (unknown App float) |
| Total | ~151 bytes |
Write Process
TourneyMenu_WriteSave(profile, "DATA\\TOURNAMENT.SAV"):
1. CRT_OpenFileExclusive(path, 0x180) // Create/truncate exclusive
2. Close handle (file created empty)
3. FID_open(path, 0x8002) // Open for write (O_WRONLY|O_CREAT)
4. Sequential __write() calls — raw struct fields
5. __close()
Read Process
TourneyMenu_LoadSaveAndShow(profile, "DATA\\TOURNAMENT.SAV"):
1. FID_open(path, 0x8000) // Open for read
2. Sequential __read() calls — same fields in same order
3. __close()
4. Create TourneyMenu (0x111C bytes) with param_2=1 (play music)
5. Scene_AddObject() — add tourney menu to scene
Reimplementation Notes
The save format is fragile (no version, no magic, no checksum). For reimplementation:
- Add a magic header + version number
- Use JSON for debuggability
- Race time arrays are 15 floats each (one per race slot, some may be 0.0 for unplayed)
- Float sizes assumed to be IEEE 754 32-bit (4 bytes each)
Registry System (Windows)
The game uses Windows Registry for persistent settings:
- Key path: Under
HKEY_CURRENT_USER\Software\Hamsterball(or similar) App+0x54= Registry key handle
Stored Values
| Registry Key | Type | Description | Default |
|---|---|---|---|
| Sound Volume | DWORD/float | Master sound volume | 1.0 (0x3F800000) |
| Music Volume | DWORD/float | Music volume | 1.0 |
| (difficulty) | DWORD | Selected difficulty | 0 |
Registry Functions
RegKey_Open(handle)— Opens registry keyRegKey_Close(handle)— Closes registry keyRegKey_ReadString(handle, name)→ bool — Check if value existsRegistry_ReadFloat(handle, name)→ float — Read float valueRegKey_WriteDWORD(handle, name, value)— Write DWORD valueRegKeyList_AppendStr(name, hex_id)— Append to registry string list
Read on Startup
Level_ReadSoundVolume (0x466570):
Level_ReadSoundVolume(SoundDevice):
RegKey_Open(app->registry)
if RegKey_ReadString("Sound Volume"):
device->volume = Registry_ReadFloat("Sound Volume")
else:
device->volume = 1.0
RegKey_Close()
Write on Shutdown
SoundDevice_dtor (0x4668A0):
SoundDevice_dtor(device):
RegKey_Open(app->registry)
RegKey_WriteDWORD("Sound Volume", device->volume)
RegKey_Close()
... cleanup ...
High Score System
High scores are gated behind registration:
- Demo version: "BUY HAMSTERBALL AND YOU CAN SAVE YOUR HIGH SCORES!"
- Full version: Scores saved to registry or local file
- No separate high score file found — likely stored in registry
Demo vs Full Version Gates
The binary contains extensive demo limitation code:
- "You have reached the end of the demo version of Hamsterball!"
- Tournament save/continue only in full version
- High scores only in full version
- Registration check via name+serial (at http://www.raptisoft.com/buyhamsterball)
App+0x200= is_registered flagApp+0x918= has_mini_games flag (for "MG" menu button)
Key Address Map
| Address | Function | Description |
|---|---|---|
| 0x4264B0 | TourneyMenu_WriteSave | Write TOURNAMENT.SAV |
| 0x4265A0 | TourneyMenu_LoadSaveAndShow | Read TOURNAMENT.SAV + show menu |
| 0x466570 | Level_ReadSoundVolume | Read volume from registry |
| 0x4668A0 | SoundDevice_dtor | Save volume to registry on exit |
Reimplementation Notes (SDL2)
Save System
// Use JSON for save files
struct TournamentSave {
int current_race;
int unknown_field;
float race_times[15];
float race_times_alt[15];
int accumulated_time;
bool difficulty;
bool has_rollback;
bool is_mirror;
bool race_active;
float total_time;
float ranking_time;
float unknown_app_float;
};
// Save as JSON
void WriteSave(const TournamentSave& save, const char* path) {
std::ofstream f(path);
// Use nlohmann/json or simple serialization
json j = { ... };
f << j.dump(2);
}
Registry Replacement
Replace Windows Registry with a simple INI/JSON config file:
[Settings]
SoundVolume=1.0
MusicVolume=1.0
Difficulty=0
Registered=true
MiniGamesUnlocked=true
MirrorTournamentUnlocked=false
🔗 Related Documents
Save, Config & [[40558402976403|registry system]]
Save, Config & Registry System
Overview
Hamsterball uses a dual persistence system:
- Registry (ADVAPI32) for game settings and unlock flags
- Files (DATA/*.sav) for tournament progress
Both are loaded at startup (App_Initialize step 12-13) and saved at shutdown.
App_SaveAllConfig (0x4284C0)
Called when game exits or settings change. Writes to Windows registry:
Registry Path: HKEY_CURRENT_USER\/software\reapti\banana (likely Hamsterball)
→ Based on RegKey_SetSoftwarePath (0x472F50) called from startup
Fields written:
MouseSensitivity DWORD (App+0x84C, float as DWORD)
MirrorTournament BOOL (App+0x850)
// Race unlock flags (App+0x851..0x859)
DizzyRace BOOL
TowerRace BOOL
UpRace BOOL
ExpertRace BOOL
OddRace BOOL
ToobRace BOOL
WobblyRace BOOL
SkyRace BOOL
MasterRace BOOL
// Arena unlock flags (App+0x85A..0x868)
DizzyArena BOOL
TowerArena BOOL
UpArena BOOL
ExpertArena BOOL
NeonRace BOOL // Note: Neon is race, not arena
GlassRace BOOL
ImpossibleRace BOOL
NeonArena BOOL
GlassArena BOOL
ImpossibleArena BOOL
OddArena BOOL
ToobArena BOOL
WobblyArena BOOL
SkyArena BOOL
MasterArena BOOL
// Binary blobs (0x50 = 80 bytes each)
BestTime BINARY (App+0x86C) - array of best times per level
Medals BINARY (App+0x8BC) - array of medal status per level
// 2P controller mappings
2PController1 DWORD (App+0xB28)
2PController2 DWORD (App+0xB2C)
2PController3 DWORD (App+0xB30)
2PController4 DWORD (App+0xB34)
App Struct Field Layout (Config)
| Offset | Type | Description |
|---|---|---|
| App+0x54 | RegKey* | Registry handle |
| App+0x84C | float | MouseSensitivity |
| App+0x850 | bool | MirrorTournament |
| App+0x851 | bool | DizzyRace |
| App+0x852 | bool | TowerRace |
| App+0x853 | bool | UpRace |
| App+0x854 | bool | ExpertRace |
| App+0x855 | bool | OddRace |
| App+0x856 | bool | ToobRace |
| App+0x857 | bool | WobblyRace |
| App+0x858 | bool | SkyRace |
| App+0x859 | bool | MasterRace |
| App+0x85A | bool | DizzyArena |
| App+0x85B | bool | TowerArena |
| App+0x85C | bool | UpArena |
| App+0x85D | bool | ExpertArena |
| App+0x85E | bool | OddArena |
| App+0x85F | bool | ToobArena |
| App+0x860 | bool | WobblyArena |
| App+0x861 | bool | SkyArena |
| App+0x862 | bool | MasterArena |
| App+0x863 | bool | NeonRace |
| App+0x864 | bool | GlassRace |
| App+0x865 | bool | ImpossibleRace |
| App+0x866 | bool | NeonArena |
| App+0x867 | bool | GlassArena |
| App+0x868 | bool | ImpossibleArena |
| App+0x86C | uint8[0x50] | BestTime (per-level best times, in milliseconds) |
| App+0x8BC | uint8[0x50] | Medals (per-level medal status: 0=none, 1=bronze, 2=silver, 3=gold) |
| App+0xB28 | DWORD | 2PController1 (DirectInput device index) |
| App+0xB2C | DWORD | 2PController2 |
| App+0xB30 | DWORD | 2PController3 |
| App+0xB34 | DWORD | 2PController4 |
LoadOrSaveConfig (0x4279F0)
Called at shutdown. Frees all config-related resources:
- Closes registry (RegKey_Close)
- Frees music channels (vtable[0x243], vtable[0x244])
- Saves to DATA/*.sav files via vtable[0x8C]+8
- Clears all level data at App+0x88..0x9D
- Calls ShellExecuteA to open raptisoft.com if no reg key flag set (0x80)
- Calls App_Shutdown to close window
Tourney_SaveTournament (0x446730)
Saves tournament progress to DATA\tournament.sav:
1. vtable[0x40]() - flush pending writes
2. CRT_remove() - delete existing file
3. vtable[0x54](&DAT_004d48a0) - write tournament save data
DAT_004D48A0 is a global string constant containing the save format signature.
Registry Functions
| Address | Name | Description |
|---|---|---|
| 0x472EA0 | RegKey_Ctor | Constructor |
| 0x472EC0 | RegKey_Open | Open registry key |
| 0x472F30 | RegKey_Close | Close registry key |
| 0x472F50 | RegKey_SetSoftwarePath | Set software name for key path |
| 0x472FD0 | RegKey_WriteDword | Write DWORD value |
| 0x473000 | RegKey_WriteDWORD | Write DWORD (alternate) |
| 0x473050 | RegKey_WriteBool | Write boolean |
| 0x473080 | RegKey_ReadDword | Read DWORD |
| 0x4730A0 | RegKey_ReadDword | Read DWORD (alternate) |
| 0x473130 | RegKey_ReadBool | Read boolean |
| 0x473170 | RegKey_ReadString | Read string |
| 0x473100 | RegKey_QueryValue | Query registry value |
| 0x473220 | RegKey_DeletingDtor | Deleting destructor |
| 0x46A0E0 | RegKeyList_AppendStr | Append string to list |
| 0x46A3C0 | RegKeyList_CopyFromSibling | Copy from sibling key |
Tournament Save Format
The TOURNAMENT.SAV file format (DATA\tournament.sav):
- Written via Tourney vtable[0x54]
- Signature: DAT_004D48A0 (likely a magic header string)
- Contains: race completion status, best times, medal counts
- Deleted and rewritten each save cycle (atomic write pattern)
Config File: DATA/
| File | Description |
|---|---|
| HS.CFG | Main config (loaded via vtable[0x8C]+8) |
| tournament.sav | Tournament progress save |
Startup Sequence (App_Initialize_Full 0x429530)
Step 12: RegKey_Open(App+0x54)
Step 13: RegKey_ReadString/ReadDword(App+0x54, PlayCount)
Step 15-22: 4x InputDevice_SetType (types 1=kb, 2=mouse, 4=joy1, 5=joy2)
Step 23: RegKey_Close(App+0x54)
On exit: App_SaveAllConfig → LoadOrSaveConfig → Tourney_SaveTournament
🔗 Related Documents
Sawblade & Drawbridge System
Sawblade & Drawbridge System (Expert + Tower Races)
Complete reverse-engineering analysis of the Sawblade (Expert Race) and Drawbridge (Tower Race) systems.
SAWBLADE (Expert Race)
Binary Addresses
| Function | Address | Purpose |
|---|---|---|
| CreateSawblade (Arena Factory) | 0x40E590 | Factory — creates SAWBLADE from level refs |
| Sawblade_Level_Ctor | 0x434660 | Constructor |
| Sawblade_Update (vtable[11]) | 0x439BB0 | Per-frame update (spin, debris, collision, movement) |
| Sawblade_Render (vtable[18]) | 0x4347E0 | Renders saw mesh + effects |
| Saw_Activate | 0x434A50 | Activates saw (E:ACTIVATESAW1/2) |
| Saw_AlertActivate | 0x434770 | Alert sound (E:ALERTSAW1/2) |
| Sawblade_SetActive | 0x434640 | Set active state |
| Sawblade_SetBreakSound | 0x434AB0 | Assign break sound |
| Sawblade_Level_Dtor | 0x434760 | Destructor |
| Sawblade vtable | 0x4D5240 | Vtable pointer |
Strings
| String | VA | Purpose |
|---|---|---|
SAWBLADE |
0x4CFA28 | Factory lookup (strnicmp 8 chars) |
Meshes\sawblade |
0x4D3390 | Mesh file |
SAW1-BREAK / SAW2-BREAK |
— | Break sound names |
SAWPATH / SMALLSAWPATH |
— | Path names for saw movement |
sounds\saw / sawcut / sawspeedy / sawstartup |
— | Sound files |
Object Structure (0x111C bytes = 4380)
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x0000 | 4 | ptr | Vtable (0x4D5240) |
| +0x10D0 | 4 | ptr | Board pointer |
| +0x10D4 | 4 | float | Position X (current, moves during activation) |
| +0x10D8 | 4 | float | Position Y |
| +0x10DC | 4 | float | Position Z (current, moves during activation) |
| +0x10E0 | 4 | float | Home position X |
| +0x10E4 | 4 | float | Home position Y |
| +0x10E8 | 4 | float | Home position Z (boundary check) |
| +0x10EC | 4 | int | Unused? |
| +0x10F0 | 4 | float | Rotation angle (RNG 0–360, decreases = clockwise) |
| +0x10F4 | 4 | float | Spin speed accumulator (0 → 25.0) |
| +0x10F8 | 4 | int | Direction (1=Z-axis movement, 2=X-axis movement) |
| +0x10FC | 4 | int | Debris spawn counter |
| +0x1100 | 4 | float | X-axis boundary limit |
| +0x1108 | 4 | float | Z-axis boundary limit |
| +0x110C | 1 | byte | Break triggered flag |
| +0x110D | 1 | byte | Alert flag (1=can alert, 0=already alerted) |
| +0x1110 | 4 | float | Spin speed (500.0 initial, decays ×0.95 when not alerted) |
| +0x1114 | 1 | byte | Activated flag (0=idle, 1=active/moving) |
| +0x1118 | 4 | float | Movement velocity (accumulates) |
Creation Flow
Factory (CreateSawblade @ 0x40E590, Arena mode only):
if (strnicmp(name, "SAWBLADE", 8) == 0 && App+0x23C != 0) { // difficulty gate!
obj = operator_new(0x111C); // 4380 bytes
Sawblade_Level_Ctor(obj, board, x, y, z);
AthenaList_Append(board+0x2578, obj); // general list
if (strstr(name, "1")) {
board+0x4370 = obj; // saw 1
Sawblade_SetBreakSound(obj, 1);
}
if (strstr(name, "2")) {
board+0x4374 = obj; // saw 2
Sawblade_SetBreakSound(obj, 2);
}
}
Constructor (Sawblade_Level_Ctor @ 0x434660):
Level_ctor(this, d3d_device); // loads "Meshes\sawblade" mesh internally
vtable = 0x4D5240;
board = param_1; // +0x10D0
pos = {x, y, z}; // +0x10D4 (current) AND +0x10E0 (home)
rotation = RNG(0, 360); // +0x10F0
spin_speed = 0; // +0x10F4
alert_flag = 1; // +0x110D (can alert)
spin_velocity = 500.0; // +0x1110
activated = 0; // +0x1114
velocity = 0; // +0x1118
Activation
Two-stage activation via collision events in ExpertCollisionEvents (0x40E6A0):
-
Alert (
E:ALERTSAW1/E:ALERTSAW2):Saw_AlertActivate(board+0x4370 or board+0x4374); // If alert_flag (+0x110D) != 0: set to 0, play alert sound -
Activate (
E:ACTIVATESAW1/E:ACTIVATESAW2):Saw_Activate(board+0x4370 or board+0x4374); // Set +0x1114 = 1 (activated), play startup sound
Update Function (vtable[11] @ 0x439BB0)
void Sawblade_Update(this) {
if (alert_flag == 0) { // +0x110D — no longer alerting
// Decay spin speed
spin_velocity *= 0.95; // +0x1110
if (spin_velocity < 1.0) spin_velocity = 0;
// Build up movement speed
spin_speed += 0.1; // +0x10F4
if (spin_speed > 25.0) spin_speed = 25.0;
// Debris spawning
debris_counter--; // +0x10FC
if (debris_counter <= 0) {
if (activated) debris_counter = 2; // fast debris when active
else debris_counter = 10; // slow debris when idle
spawn_debris_particles(this);
}
// Movement (only when activated)
if (activated) { // +0x1114
if (direction == 1) { // +0x10F8 — Z-axis
pos.z -= velocity; // +0x10DC
velocity += 4.17e-8; // +0x1118 (gravity-like accel)
if (abs(pos.z - home.z) >= boundary) {
// Hit boundary — trigger break
if (!break_triggered) {
break_triggered = 1; // +0x110C
SceneObject_SpawnWithSound(board+0x4380);
}
}
}
if (direction == 2) { // X-axis
pos.x += velocity; // +0x10D4
velocity += 4.17e-8;
// Similar boundary check with board+0x4798
}
}
}
// Ball collision detection (always runs)
for each ball in board+0x29D4:
distance = sqrt((ball.x-pos.x)² + (ball.y-pos.y)² + (ball.z-pos.z)²)
if (distance <= ball.radius + 100.0) { // 100.0 = saw kill radius
// Alignment check (axis-specific)
if (alignment < ball.radius) {
ball->vtable[8](); // call damage/split function
}
}
// Update rotation
rotation -= spin_speed; // +0x10F0 (clockwise spin)
}
Key Constants
| Address | Value | Purpose |
|---|---|---|
| 0x4D092C | 0.95 | Spin velocity decay |
| 0x4D0924 | 0.1 | Spin speed increment |
| 0x4CFECC | 25.0 | Max spin speed |
| 0x4CF4DC | 8.0 | Speed range check |
| 0x4CF48C | 2.0 | Speed range check |
| 0x4CF480 | 75.0 | Position offset for debris |
| 0x4D5C78 | 4.17e-8 | Velocity increment (gravity) |
| 0x4D5C68 | 9999.0 | Large boundary value |
| 0x4CF454 | 100.0 | Ball collision radius |
| 0x4CF310 | 1.0 | Minimum spin threshold |
Vtable (0x4D5240)
| Index | Offset | Address | Function |
|---|---|---|---|
| 0 | 0x00 | 0x439B90 | Sawblade_Level_scalar_dtor |
| 11 | 0x2C | 0x439BB0 | Sawblade_Update |
| 18 | 0x48 | 0x4347E0 | Sawblade_Render |
DRAWBRIDGE (Tower Race)
The Tower race has TWO types of bridge objects:
Type 1: BRIDGE (Spinning Drawbridge Platform)
Created by the Arena factory (CreateSawblade @ 0x40E590) for Arena mode.
In Tower RACE mode, bridges are pre-placed (configured via CreateLevelObjects).
| Function | Address | Purpose |
|---|---|---|
| Spinner_Level_ctor | 0x4396F0 | Constructor |
| Spinner_Update (vtable[11]) | 0x439870 | Per-frame update (spin animation) |
| Spinner_Render (vtable[18]) | 0x45E0E0 | Render |
| Spinner vtable | 0x4D51E0 | Vtable pointer |
Object Structure (0x10FC bytes = 4348):
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x10D0 | 4 | ptr | Board pointer |
| +0x10D4 | 4 | float | Position X |
| +0x10D8 | 4 | float | Position Y |
| +0x10DC | 4 | float | Position Z |
| +0x10E0 | 4 | float | Rotation angle |
| +0x10E4 | 4 | int | State (0=normal) |
| +0x10E8 | 4 | float | Angular velocity |
| +0x10F0 | 4 | int | Timer (init 100) |
| +0x10F4 | 4 | ptr | CollisionLevel pointer |
| +0x10F8 | 4 | float | Direction multiplier (1.0 or -1.0) |
Constructor (Spinner_Level_ctor @ 0x4396F0):
Stands_ctor(this, board+0x4378); // uses pre-loaded mesh at board+0x4378
vtable = 0x4D51E0; // Spinner vtable
board = param_1; // +0x10D0
pos = {x, y, z}; // +0x10D4
rotation = param_5; // +0x10E0
angular_velocity = 0; // +0x10E8
timer = 100; // +0x10F0
direction = 1.0; // +0x10F8
// Creates CollisionLevel internally
collision_level = CollisionLevel_ctorWithLevel(operator_new(0x10D0), this);
Mesh Dependency: Stands_ctor reads from board+0x4378. The Tower constructor loads Levels\Level4-Drawbridge at board+0x4370 and Levels\Level4-Mace at board+0x4378. For global spawn, use JIT mesh injection: save board+0x4378, load drawbridge mesh there, call ctor, restore.
Type 2: BBRIDGE (Breakable Bridge Sections)
Created by CreateLevelObjects (0x4121D0) for "BBRIDGE1" / "BBRIDGE2" refs.
| Function | Address | Purpose |
|---|---|---|
| BreakBridge_ctor | 0x436D70 | Constructor |
| BreakBridge_Update (vtable[11]) | 0x43DD80 | Per-frame update |
| BreakBridge_Render (vtable[18]) | 0x45E0E0 | Render |
| BreakBridge vtable | 0x4D5890 | Vtable pointer |
Object Structure (0x1100 bytes = 4352):
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x10D0 | 4 | ptr | Board pointer |
| +0x10D4 | 4 | float | Position X |
| +0x10D8 | 4 | float | Position Y |
| +0x10DC | 4 | float | Position Z |
| +0x10E0 | 4 | ptr | CollisionLevel pointer |
| +0x10E4 | 1 | byte | Active flag (1=active) |
| +0x10E8 | 4 | float | Z position (boundary) |
| +0x10EC | 4 | float | Position offset |
| +0x10F0 | 4 | float | Position offset |
| +0x10F4 | 4 | int | Trigger counter (0=not triggered) |
| +0x10F8 | 4 | float | Direction (0.0) |
| +0x10FC | 1 | byte | Active flag 2 |
Constructor (BreakBridge_ctor @ 0x436D70):
Stands_ctor(this, mesh_ptr); // uses pre-loaded mesh (board+0x5410 or +0x5414)
vtable = 0x4D5890; // Pendulum/BreakBridge vtable
board = param_1;
pos = {x, y, z};
collision_level = CollisionLevel_ctorWithLevel(operator_new(0x10D0), this);
active = 0; // +0x10FC
trigger_counter = 0; // +0x10F4
active_flag = 1; // +0x10E4
Tower Level Pre-loaded Meshes
LevelBoard_Tower_ctor (0x41E340) loads:
| Board Offset | Mesh File | Purpose |
|---|---|---|
| +0x436C | Levels\Level4-Catapult |
Catapult mesh |
| +0x4370 | Levels\Level4-Drawbridge |
Drawbridge mesh |
| +0x4374 | Meshes\YellowLink |
Yellow link mesh |
| +0x4378 | Levels\Level4-Mace |
Mace mesh |
| +0x437C | Levels\Level4-Windmill |
Windmill mesh |
| +0x4390 | Meshes\Chomper |
Chomper mesh |
| +0x43B4 | Levels\Level4-Turret |
Turret mesh |
Arena Collision Events (Tower/Expert Arena)
| Event | Action |
|---|---|
E:ALERTSAW1 |
Saw_AlertActivate(board+0x4370) — alert saw 1 |
E:ALERTSAW2 |
Saw_AlertActivate(board+0x4374) — alert saw 2 |
E:ACTIVATESAW1 |
Saw_Activate(board+0x4370) — activate saw 1 |
E:ACTIVATESAW2 |
Saw_Activate(board+0x4374) — activate saw 2 |
All saw events require App+0x23C != 0 (difficulty gate — Normal/Frenzied only).
Global Spawn Approach
Sawblade Global Spawn
Uses the same pattern as Global Bonk:
- Hook at Ball_Update (0x405E22)
- Save player position
- On spawn trigger:
operator_new(0x111C)Sawblade_Level_Ctor(obj, board, x, y, z)— loads saw mesh internally- Set
+0x10F8 = 1(direction 1, Z-axis) or2(X-axis) AthenaList_Append(board+0x2578, obj)— register to general list- Append collision level to
board+0x10EC
- Per-frame: call
Saw_Activate(obj)to auto-activate (set+0x1114=1)
No pre-loaded mesh needed — Level_ctor loads Meshes\sawblade internally.
Drawbridge Global Spawn
Uses JIT mesh injection (same pattern as Global Lifters):
- Pre-load
Levels\Level4-Drawbridgemesh →board+0x4378(temporarily) - Save old
board+0x4378value operator_new(0x10FC)Spinner_Level_ctor(obj, board, x, y, z, 0.0)— reads mesh fromboard+0x4378- Restore
board+0x4378 AthenaList_Append(board+0x2578, obj)- Append collision level to scene lists
The drawbridge mesh is loaded once and cached for subsequent spawns.
🔗 Related Documents
Scene Object
Scene Object — Complete Modder's Reference
Verified via direct Ghidra decompilation of
Hamsterball.exe(Athena engine, PE32 i386).
All offsets below were extracted from the live decompiled code via the GhidraMCP headless server.
Confidence markers: ✅ = Verified in raw decompiled C (2+ functions), ⚠️ = Verified in 1 function only, ❓ = Inferred from struct layout / not found in raw C.
Quick Stats
| Property | Value |
|---|---|
| Primary update function | Scene_Update @ 0x00419C00 |
| Ball physics function | Scene_UpdateBallsAndState @ 0x0041B540 |
| Render function | Scene_Render @ 0x0041A2E0 |
| VTable | 0x004D0260 |
| Destructor | Scene_dtor @ 0x00419770 |
| Total struct size | ~0x5000 bytes (inherits from Gadget, extends to 0x4FD4+) |
How to Get the Scene Pointer
Method 1: From the Global App Singleton (Easiest)
the app object lives at g_App = 0x004FD680. It stores the current Scene pointer at App+0x5DC.
// Global App singleton
void** g_App = (void**)0x004FD680;
// Get current Scene pointer
void* scene = *(void**)((char*)g_App + 0x5DC);
Alternative offsets into App for Scene access:
| App Offset | Points to | Verified By |
|---|---|---|
+0x5DC |
Current Scene* | Scene_Update reads position from App+0x5DC→+0x758 |
+0x878 |
Scene back-pointer | Collision handler: int* app = *(int**)((int)this + 0x878) |
Method 2: From Any Ball Object
Every Ball stores a back-pointer to its parent Scene at Ball+0x14:
// ball is any Ball* pointer
void* scene = *(void**)((char*)ball + 0x14);
Verified by: Ball_ctor2 @ 0x004039E0 stores param_1 (Scene*) at this+0x14.
Method 3: From Any SceneObject
SceneObjects embed a back-pointer to Scene/App:
// SceneObject has app_ptr at +0x10 (from Gadget inheritance)
void* scene = *(void**)((char*)sceneObject + 0x10);
Method 4: Hook Scene_Update (0x419C00)
// Hook the main game tick — param_1 IS the Scene*
void __fastcall MySceneUpdate(void* scene) {
printf("Scene tick: frame=%d\n", *(int*)((char*)scene + 0x3620));
OriginalSceneUpdate(scene);
}
Complete Scene Struct Layout (Address-Ordered)
Source:
SCENE_SYSTEM_DECOMP.md(explicit byte offsets from int-indexed Ghidra fields),decomp_scene_update.c,decomp_scene_spawnballs.c,decomp_level_render.c,decomp_collision_events.c
Inheritance Chain
GameObject (base)
└─ Gadget (+0x870 bytes)
└─ Scene (extends Gadget, adds game state)
Scene inherits from Gadget. The Gadget base contains:
vtable_ptrat+0x000app_ptrat+0x014(App* back-pointer)- Lists, transform data, name string
How to Read This Table
- All offsets are byte addresses from the start of the Scene struct.
int-indexedfields from decomp outputs have been multiplied by×4.- ⚠️ = From comment-only decomp files (no raw C code available)
- ❓ = Inferred from single decomp source — may need confirmation.
| Offset | Type | Field | Description | Confidence |
|---|---|---|---|---|
+0x000 |
void** |
vtable |
Scene vtable 0x4D0260 |
✅ |
+0x004 |
— | (Gadget base) |
Inherited fields — vtable, refs, transform data | ✅ |
+0x014 |
App* |
app_ptr |
Gadget-level back-pointer to App singleton 0x4FD680 |
✅ |
+0x868 |
char* |
name |
"Generic Gadget" / "Board" (Gadget inherited) |
✅ |
+0x870 |
— | (Scene extension start) |
First field after Gadget base (0x870 bytes) | ✅ |
+0x874 |
byte |
is_skydome |
0 = skybox, 1 = skydome | ✅ |
+0x878 |
App* |
scene_manager |
D3D device / render container (also resolves as App* in some contexts) | ✅ |
+0x87C |
void* |
viewport_obj |
D3D viewport interface | ✅ |
+0x884 |
ArenaBoard |
rumble_timer_1 |
First rumble/haptics timer (0x14 bytes) | ✅ |
+0x898 |
ArenaBoard |
rumble_timer_2 |
Second rumble/haptics timer (0x14 bytes) | ✅ |
+0x8AC |
Level* |
level_ptr |
Level geometry / collision data | ✅ |
+0x8B0 |
Level* |
skydome_ptr |
Skydome level (alternative sky rendering) | ✅ |
+0x8B8 |
AthenaList |
scene_object_list |
All scene objects (update + render pass) | ✅ |
+0x8BC |
int |
scene_object_count |
Number of objects in above list | ✅ |
+0x8C0 |
int |
scene_object_iterator |
Current iteration index ❓ | ❓ |
+0x910 |
WaypointList* |
waypoint_list |
Race checkpoint tracking | ⚠️️ |
+0xCC4 |
SceneObject** |
scene_object_array |
Direct pointer array to scene objects | ✅ |
+0x1518 |
AthenaList |
collision_list |
Collision surface list (planes, walls, floors) | ✅ |
+0x151C |
int |
collision_count |
Number of collision entries | ❓ |
+0x1520 |
int |
collision_iterator |
Collision list iteration index ❓ | ❓ |
+0x1924 |
void** |
collision_array |
Collision object pointer array | ❓ |
+0x2160 |
AthenaList |
ripple_list |
Water ripple effects | ✅ |
+0x29B0 |
byte |
ball_positions_dirty |
1 = need to propagate ball positions this frame | ✅ |
+0x29B8 |
int |
shake_magnitude |
Camera shake intensity. Starts at -800, decays by +10/frame | ✅ |
+0x29BC |
float |
camera_orbit_angle |
Y-axis orbit rotation around ball | ✅ |
+0x29C0 |
float |
camera_distance |
Orbit distance from ball | ✅ |
+0x29D0 |
Ball* |
current_ball_ptr |
Ball currently tracked by camera | ✅ |
+0x29D4 |
AthenaList |
ball_list_1 |
Player 1 ball list head | ✅ |
+0x29D8 |
int |
ball_list_1_count |
Number of P1 balls | ✅ |
+0x29DC |
int |
ball_list_1_iterator |
P1 list iteration state ❓ | ❓ |
+0x2DE0 |
Ball** |
ball_list_1_array |
P1 Ball pointer array — direct read | ✅ |
+0x3204 |
AthenaList |
ball_list_2 |
Player 2 ball list head (split-screen) | ✅ |
+0x3208 |
int |
ball_list_2_count |
Number of P2 balls | ✅ |
+0x320C |
int |
ball_list_2_iterator |
P2 list iteration state ❓ | ❓ |
+0x3610 |
Ball** |
ball_list_2_array |
P2 Ball pointer array — direct read | ✅ |
+0x361C |
SceneObject* |
waypoint_arrow |
Next-waypoint arrow | ✅ |
+0x3620 |
int |
frame_counter |
Total frames since scene start. Also called tick_count |
✅ |
+0x362C |
AthenaList |
player_list |
Player viewport list. Decomp calls this physics_objects at int +0x0D8B |
✅ |
+0x3630 |
int |
player_count |
0=none, 1=single player, 2=split-screen | ✅ |
+0x3634 |
int |
player_iterator |
Player list iteration state ❓ | ❓ |
+0x3A38 |
Ball** |
player_ball_array |
Array of ball pointers indexed by player (0-3) | ✅ |
+0x3A44 |
byte |
use_skydome |
0 = skybox, 1 = skydome | ❓ |
+0x3A48 |
AthenaList |
visible_object_list |
Objects visible this frame (render bucket) | ✅ |
+0x3A4C |
byte |
shake_active |
1 = camera shake / haptics currently active | ✅ |
+0x3AFC |
void* |
dynamic_object |
Object with vtable[8] render callback. Also called post_update_callback_obj |
✅ |
+0x3B00 |
void* |
trail_particles_ptr |
Trail particle system pointer | ❓ |
+0x3F18 |
void* |
water_ripple |
Water ripple renderer object | ✅ |
+0x3F1C |
byte |
path_follow_mode |
1 = camera rides spline rails | ✅ |
+0x3F20 |
void* |
path_object |
Spline path data for camera rails | ✅ |
+0x3F24 |
float |
path_position |
Parametric t-position on spline | ✅ |
+0x3F2C |
int |
camera_snap_frames |
Frames remaining until snap-to-ball | ✅ |
+0x434C |
float |
camera_offset_x |
Camera offset X from ball | ✅ |
+0x4350 |
float |
camera_offset_y |
Camera offset Y from ball | ✅ |
+0x4354 |
float |
camera_offset_z |
Camera offset Z from ball | ✅ |
+0x4358 |
byte |
demo_timer_active |
1 = demo countdown running | ✅ |
+0x435C |
int |
demo_countdown |
Frames remaining until demo popup | ✅ |
+0x4360 |
float |
demo_accumulator |
Popup timing accumulator | ✅ |
+0x4364 |
int |
demo_frame_counter |
Demo tick counter | ✅ |
+0x4368 |
byte |
demo_menu_suppressed |
1 = block ESC menu during demo | ✅ |
+0x436C |
void* |
hammer_obj |
Bonk/hammer object (arena) | ✅ |
+0x4370 |
void* |
saw1_obj |
Saw blade 1 object (arena) | ✅ |
+0x4374 |
void* |
saw2_obj |
Saw blade 2 object (arena) | ✅ |
+0x43A0 |
float |
damage_amount |
Current damage value (set by E:BITE) | ✅ |
+0x43A8 |
int |
damage_timer |
Damage countdown timer | ✅ |
+0x43B8 |
void* |
catapult_list |
Catapult object list | ✅ |
+0x47D0 |
void* |
door_list |
Trapdoor/door object list | ✅ |
+0x4BBC |
void* |
judge_list |
Judge/score display list (arena) | ✅ |
+0x4BEC |
void* |
door_list_alt |
Alternate door list | ✅ |
+0x4FD4 |
void* |
bell_obj |
Bell object (arena extra time) | ✅ |
Scene Vtable Map (0x4D0260)
| Index | Address | Name | Description | Calling Convention |
|---|---|---|---|---|
[0x00] |
0x425020 |
Scene_ctor |
Constructor | __thiscall |
[0x04] |
0x419C00 |
Scene_Update |
Main game tick | __thiscall |
[0x08] |
0x41A2E0 |
Scene_Render |
Render dispatch (1P/2P) | __thiscall |
[0x0C] |
0x4692F0 |
Scene_dtor_thunk |
Destructor thunk | __thiscall |
[0x10] |
0x469220 |
Scene_Release |
Release reference | __thiscall |
[0x14] |
0x4130A0 |
Scene_SetupLevel |
Level setup | __thiscall |
[0x18] |
0x469280 |
Scene_SceneObjThunk1 |
Thunk 1 | __thiscall |
[0x1C] |
0x409D90 |
Scene_Init |
Initialize scene | __thiscall |
[0x20] |
0x40B400 |
Scene_RunTick |
Run single tick | __thiscall |
[0x24] |
0x44B840 |
Scene_NoOp1 |
No-op stub | — |
[0x28] |
0x44B840 |
Scene_NoOp2 |
No-op stub | — |
[0x2C] |
0x4692A0 |
Scene_SceneObjThunk2 |
Thunk 2 | __thiscall |
[0x30] |
0x4692A0 |
Scene_SceneObjThunk3 |
Thunk 3 | __thiscall |
[0x34] |
0x44B840 |
Scene_NoOp3 |
No-op stub | — |
[0x38] |
0x409DA0 |
Scene_LoadLevel |
Load level file | __thiscall |
[0x3C] |
0x469430 |
Scene_SceneObjThunk4 |
Thunk 4 | __thiscall |
[0x40] |
0x419740 |
Scene_CleanupScene |
Cleanup all state | __thiscall |
[0x44] |
0x4692B0 |
Scene_SceneObjThunk5 |
Thunk 5 | __thiscall |
[0x48] |
0x40B090 |
Scene_StartRace |
Start race countdown | __thiscall |
[0x4C] |
0x41B130 |
Scene_HandleRaceEnd |
Check finish condition | __thiscall |
[0x50] |
0x41B540 |
Scene_UpdateBallsAndState |
Ball physics + respawn | __thiscall |
[0x54] |
0x40A040 |
Scene_NoOp_Collision |
Stub | — |
[0x58] |
0x41A540 |
Scene_ProcessRaceEnd |
Race countdown | __thiscall |
[0x5C] |
0x409DE0 |
Scene_?? |
Unknown | — |
[0x60] |
0x40B420 |
Level_RenderDynamicObjects |
Sky/ripples/dynamic | __thiscall |
[0x64] |
0x40B600 |
Level_UpdateAndRender |
Main level render | __thiscall |
[0x68] |
0x40B570 |
Level_RenderObjects |
Transparent pass | __thiscall |
[0x6C] |
0x41B710 |
Scene_RenderOverlay |
HUD/score overlay | __thiscall |
[0x70] |
0x41BFD0 |
Scene_RenderPostEffects |
Fade/transition effects | __thiscall |
[0x74] |
0x40C5D0 |
Scene_?? |
Unknown | — |
[0x78] |
0x44B840 |
Scene_NoOp4 |
No-op stub | — |
[0x7C] |
0x41AC70 |
Scene_LevelObjUpdate |
Level object tick | __thiscall |
[0x80] |
0x41C5B0 |
Scene_?? |
Unknown | — |
[0x84] |
0x419750 |
Scene_?? |
Unknown | — |
[0x88] |
0x44B840 |
Scene_NoOp5 |
No-op stub | — |
[0x8C] |
0x41A9A0 |
Scene_ComputeInputForceDirection |
Computes 3D force vector from strongest player input (Ghidra label "ComputeLighting" is wrong) | __thiscall |
How to Iterate All Balls
Method 1: Array Access (Fastest)
// Get scene pointer
g_App = (char*)0x004FD680;
void* scene = *(void**)(g_App + 0x5DC);
// Player 1 balls
int p1_count = *(int*)((char*)scene + 0x29D8);
void** p1_balls = *(void***)((char*)scene + 0x2DE0);
for (int i = 0; i < p1_count; i++) {
void* ball = p1_balls[i];
int player_idx = *(int*)((char*)ball + 0x18); // -1=AI, 0-3=human
float x = *(float*)((char*)ball + 0x164);
float y = *(float*)((char*)ball + 0x168);
float z = *(float*)((char*)ball + 0x16C);
// ... mod logic
}
// Player 2 balls (split-screen)
int p2_count = *(int*)((char*)scene + 0x3208);
void** p2_balls = *(void***)((char*)scene + 0x3610);
for (int i = 0; i < p2_count; i++) {
void* ball = p2_balls[i];
// ... mod logic
}
Method 2: Hook Scene_UpdateBallsAndState (0x41B540)
typedef void (__fastcall *UpdateBallsFunc)(void* scene);
UpdateBallsFunc orig = (UpdateBallsFunc)0x0041B540;
void __fastcall Hook_UpdateBallsAndState(void* scene) {
int p1_count = *(int*)((char*)scene + 0x29D8);
void** p1_balls = *(void***)((char*)scene + 0x2DE0);
for (int i = 0; i < p1_count; i++) {
void* ball = p1_balls[i];
// ... pre-tick modifications
}
orig(scene);
}
Modding Recipes
Recipe 1: Get Current Player's Ball
void* GetPlayerBall(int player_index) {
void* scene = *(void**)((char*)0x004FD680 + 0x5DC);
void** player_balls = *(void***)((char*)scene + 0x3A38);
return player_balls[player_index]; // 0 = P1, 1 = P2
}
Recipe 2: Teleport All Balls to Position
void TeleportAllBalls(float x, float y, float z) {
void* scene = *(void**)((char*)0x004FD680 + 0x5DC);
int p1_count = *(int*)((char*)scene + 0x29D8);
void** p1_balls = *(void***)((char*)scene + 0x2DE0);
for (int i = 0; i < p1_count; i++) {
void* ball = p1_balls[i];
*(float*)((char*)ball + 0x164) = x;
*(float*)((char*)ball + 0x168) = y;
*(float*)((char*)ball + 0x16C) = z;
*(char*)((char*)ball + 0xC3C) = 1; // teleport_flag
}
int p2_count = *(int*)((char*)scene + 0x3208);
void** p2_balls = *(void***)((char*)scene + 0x3610);
for (int i = 0; i < p2_count; i++) {
void* ball = p2_balls[i];
*(float*)((char*)ball + 0x164) = x;
*(float*)((char*)ball + 0x168) = y;
*(float*)((char*)ball + 0x16C) = z;
*(char*)((char*)ball + 0xC3C) = 1;
}
}
Recipe 3: Force Pause
void ForcePauseGame() {
void* scene = *(void**)((char*)0x004FD680 + 0x5DC);
*(char*)((char*)scene + 0x220) = 1; // paused_flag = true
}
Recipe 4: Read Camera State
void GetCameraState(float* orbit_angle, float* distance, float* offset) {
void* scene = *(void**)((char*)0x004FD680 + 0x5DC);
*orbit_angle = *(float*)((char*)scene + 0x29BC);
*distance = *(float*)((char*)scene + 0x29C0);
offset[0] = *(float*)((char*)scene + 0x434C);
offset[1] = *(float*)((char*)scene + 0x4350);
offset[2] = *(float*)((char*)scene + 0x4354);
}
Recipe 5: Disable Demo Timer
void DisableDemoTimer() {
void* scene = *(void**)((char*)0x004FD680 + 0x5DC);
*(char*)((char*)scene + 0x4358) = 0; // demo_timer_active = false
*(char*)((char*)scene + 0x4368) = 1; // demo_menu_suppressed = true
}
Recipe 6: Trigger Camera Shake
void TriggerCameraShake(int magnitude) {
void* scene = *(void**)((char*)0x004FD680 + 0x5DC);
*(int*)((char*)scene + 0x29B8) = magnitude; // shake_magnitude
*(char*)((char*)scene + 0x3A4C) = 1; // shake_active
}
Recipe 7: Count Total Scene Objects
int CountSceneObjects() {
void* scene = *(void**)((char*)0x004FD680 + 0x5DC);
return *(int*)((char*)scene + 0x08BC); // scene_object_count
}
Recipe 8: Activate All Trapdoors
void ActivateAllTrapdoors(void* scene) {
void* door_list = *(void**)((char*)scene + 0x47D0);
// Iterate door_list, call Trapdoor_Activate(door) for each
}
AthenaList Helper
AthenaList is the engine's linked list / array hybrid:
typedef struct {
void** array; // +0x00: pointer to element array
int count; // +0x04: number of elements
int capacity; // +0x08: allocated capacity
int iterator; // +0x0C: current iteration index
void* head; // +0x10: list head pointer
void* tail; // +0x14: list tail pointer
} AthenaList;
To iterate an AthenaList manually:
void IterateAthenaList(void* scene_list_ptr, void (*callback)(void*)) {
AthenaList* list = (AthenaList*)scene_list_ptr;
for (int i = 0; i < list->count; i++) {
callback(list->array[i]);
}
}
Key Functions
| Function | Address | Parameters | Description |
|---|---|---|---|
Scene_Update |
0x419C00 |
Scene* this |
Main game tick — hooks here see all frame state |
Scene_UpdateBallsAndState |
0x41B540 |
Scene* this |
Ball physics iteration — ALL balls update through here |
Scene_Render |
0x41A2E0 |
Scene* this |
Render dispatch (1P/2P split) |
Scene_SetCamera |
0x419FA0 |
Scene*, Ball*, int |
Camera positioning around ball |
Scene_AddObject |
0x469990 |
Scene*, SceneObject* |
Add object to scene |
Scene_SpawnBallsAndObjects |
0x41C5B0 |
Scene* |
Level startup spawner |
Scene_CreateGameOverMenu |
0x40A920 |
Scene*, int |
Pause/quit menu |
Scene_CheckPath |
0x457EC0 |
int start, int target |
359-cell ring pathfinder |
Gear_AdvanceAlongPath |
0x418930 |
Gear*, float, float, float |
Spline path follower |
TowerCollisionEvents |
0x40DCD0 |
Scene*, Ball*, Collider* |
Level collision events |
| `ExpertCollisionEvents | 0x40E6A0 |
Scene*, Ball*, Collider* |
Expert board collision events |
Verification Notes
INT-INDEXED vs BYTE OFFSETS
Ghidra sometimes reports offsets as int indices (array indices into int[]). For this PE32 binary, sizeof(int) = 4. To convert:
| Int Index | Byte Offset |
|---|---|
0x021D |
0x0874 |
0x0220 |
0x0880 |
0x0A6C |
0x29B0 |
0x0D88 |
0x3620 |
0x10D6 |
0x4358 |
The decompilations in decomp_scene_update.c use int-indexed notation in comments. The offsets in this document are already converted to byte addresses.
Verified Offsets (raw decompiled C, 19 functions scanned)
Automated verification via GhidraMCP REST API decompilation of these functions:
Scene_Update, Scene_dtor, Scene_SetCamera, Scene_Render, Scene_UpdateBallsAndState,
Scene_SpawnBallsAndObjects, Scene_LevelObjUpdate, Level_UpdateAndRender, Level_RenderDynamicObjects,
Level_RenderObjects, TowerCollisionEvents, ExpertCollisionEvents, Ball_Update, Ball_ctor,
Scene_StartRace, Scene_HandleRaceEnd, Scene_ProcessRaceEnd, Scene_CreateGameOverMenu, Scene_CheckPath.
Result: 56 offsets verified in raw C. 11 offsets not found in any decompiled function body.
| Offset | Field | Verified In | Count |
|---|---|---|---|
0x0874 |
is_skydome |
Scene_CreateGameOverMenu, Scene_ProcessRaceEnd, Scene_Update |
3 |
0x0878 |
scene_manager |
ExpertCollisionEvents, TowerCollisionEvents, Level_UpdateAndRender |
3+ |
0x087C |
viewport_obj |
Scene_Render, Scene_SetCamera, Scene_dtor |
3 |
0x0884 |
rumble_timer_1 |
Scene_Update, Scene_dtor |
2 |
0x0898 |
rumble_timer_2 |
Scene_Update, Scene_dtor |
2 |
0x08AC |
level_ptr |
Level_RenderObjects, Level_UpdateAndRender, Scene_SpawnBallsAndObjects |
3 |
0x08B0 |
skydome_ptr |
Scene_dtor |
1 |
0x08B8 |
scene_object_list |
ExpertCollisionEvents, Scene_ProcessRaceEnd, Scene_Update |
3 |
0x08BC |
scene_object_count |
Scene_ProcessRaceEnd, Scene_Update, Scene_dtor |
3 |
0x0CC4 |
scene_object_array |
Scene_ProcessRaceEnd, Scene_Update, Scene_dtor |
3 |
0x1518 |
collision_list |
Scene_SpawnBallsAndObjects, Scene_dtor |
2 |
0x2160 |
ripple_list |
Scene_dtor |
1 |
0x29B0 |
ball_positions_dirty |
Scene_Update |
1 |
0x29B8 |
shake_magnitude |
Scene_Update |
1 |
0x29BC |
camera_orbit_angle |
Scene_SetCamera |
1 |
0x29C0 |
camera_distance |
Scene_SetCamera |
1 |
0x29D0 |
current_ball_ptr |
Scene_Render |
1 |
0x29D4 |
ball_list_1 |
Level_UpdateAndRender, Scene_SpawnBallsAndObjects, Scene_Update |
3 |
0x29D8 |
ball_list_1_count |
Level_UpdateAndRender, Scene_Update, Scene_UpdateBallsAndState |
3 |
0x2DE0 |
ball_list_1_array |
Level_UpdateAndRender, Scene_Update, Scene_UpdateBallsAndState |
3 |
0x3204 |
ball_list_2 |
Level_UpdateAndRender, Scene_UpdateBallsAndState, Scene_dtor |
3 |
0x3208 |
ball_list_2_count |
Level_UpdateAndRender, Scene_UpdateBallsAndState, Scene_dtor |
3 |
0x3610 |
ball_list_2_array |
Level_UpdateAndRender, Scene_UpdateBallsAndState, Scene_dtor |
3 |
0x361C |
waypoint_arrow |
Level_UpdateAndRender, Scene_dtor |
2 |
0x3620 |
frame_counter |
Scene_Update |
1 |
0x362C |
player_list |
Scene_ProcessRaceEnd, Scene_Render, Scene_SpawnBallsAndObjects |
3 |
0x3630 |
player_count |
Scene_ProcessRaceEnd, Scene_Render, Scene_SpawnBallsAndObjects |
3 |
0x3A38 |
player_ball_array |
Scene_ProcessRaceEnd, Scene_Render, Scene_SpawnBallsAndObjects |
3 |
0x3A48 |
visible_object_list |
Level_RenderObjects, Level_UpdateAndRender, Scene_dtor |
3 |
0x3A4C |
shake_active |
Scene_Update |
1 |
0x3AFC |
dynamic_object |
Scene_Update, Scene_dtor |
2 |
0x3F18 |
water_ripple |
Scene_dtor |
1 |
0x3F1C |
path_follow_mode |
Scene_SetCamera, Scene_Update |
2 |
0x3F20 |
path_object |
Scene_SetCamera, Scene_Update, Scene_dtor |
3 |
0x3F24 |
path_position |
Scene_SetCamera |
1 |
0x3F2C |
camera_snap_frames |
Scene_SetCamera |
1 |
0x434C |
camera_offset_x |
Scene_SetCamera |
1 |
0x4350 |
camera_offset_y |
Scene_SetCamera |
1 |
0x4354 |
camera_offset_z |
Scene_SetCamera |
1 |
0x4358 |
demo_timer_active |
Scene_Update |
1 |
0x435C |
demo_countdown |
Scene_Update |
1 |
0x4360 |
demo_accumulator |
Scene_Update |
1 |
0x4364 |
demo_frame_counter |
Scene_Update |
1 |
0x4368 |
demo_menu_suppressed |
Scene_Update |
1 |
0x436C |
hammer_obj |
ExpertCollisionEvents |
1 |
0x4370 |
saw1_obj |
ExpertCollisionEvents |
1 |
0x4374 |
saw2_obj |
ExpertCollisionEvents |
1 |
0x43A0 |
damage_amount |
TowerCollisionEvents |
1 |
0x43A8 |
damage_timer |
TowerCollisionEvents |
1 |
0x43B8 |
catapult_list |
TowerCollisionEvents |
1 |
0x47D0 |
door_list |
TowerCollisionEvents |
1 |
0x4BBC |
judge_list |
ExpertCollisionEvents |
1 |
0x4BEC |
door_list_alt |
TowerCollisionEvents |
1 |
0x4FD4 |
bell_obj |
ExpertCollisionEvents |
1 |
Unverified Offsets (NOT found in raw decompiled C)
These offsets are listed in the document but do not appear in any decompiled function body from the 19-function scan. They may be:
- Embedded AthenaList sub-fields (count, iterator, capacity) that are accessed inline
- Comment-only hypotheses from earlier RE sessions
- Struct fields that exist but are never directly dereferenced in the scanned functions
| Offset | Field | Note |
|---|---|---|
0x08C0 |
scene_object_iterator |
Likely AthenaList embedded iterator at scene_object_list + 0x0C |
0x151C |
collision_count |
Likely AthenaList embedded count at collision_list + 0x04 |
0x1520 |
collision_iterator |
Likely AthenaList embedded iterator at collision_list + 0x0C |
0x1924 |
collision_array |
Likely AthenaList embedded array at collision_list + 0x00 |
0x29DC |
ball_list_1_iterator |
Likely AthenaList embedded iterator at ball_list_1 + 0x0C |
0x320C |
ball_list_2_iterator |
Likely AthenaList embedded iterator at ball_list_2 + 0x0C |
0x3634 |
player_iterator |
Likely AthenaList embedded iterator at player_list + 0x0C |
0x3A44 |
use_skydome |
Field verified via is_skydome at 0x874; 0x3A44 may be a duplicate/alias |
0x3B00 |
trail_particles_ptr |
Not found in any decompiled function from the scan set |
Comment-Only Offsets
These appear only in the comment headers of decomp_scene_update.c (the raw C body is not saved in that file):
0x3620—frame_counter✅ NOW VERIFIED (found inScene_Updateraw C)0x362C—player_list✅ NOW VERIFIED (found inScene_Renderraw C)0x3630—player_count✅ NOW VERIFIED (found inScene_Renderraw C)0x3A38—player_ball_array✅ NOW VERIFIED (found inScene_Renderraw C)0x3A4C—shake_active✅ NOW VERIFIED (found inScene_Updateraw C)0x4358–0x4368— Demo timer fields ✅ ALL NOW VERIFIED (found inScene_Updateraw C)
Sources
All data verified via live GhidraMCP headless decompilation on Hamsterball.exe:
decomp_scene_update.c(0x419C00) — main game tick (comment-only header)decomp_scene_updateballs.c(0x41B540) — ball physics iteration (comment-only header)decomp_level_render.c— rendering pipeline with ball lists (comment-only header)decomp_scene_spawnballs.c(0x41C5B0) — level startup + ball creation (comment-only header)decomp_collision_events.c(0x40C5D0) — collision handler with scene offsets ✅ raw Cdecomp_tower_collisionevents.c(0x40DCD0) — Tower board events ✅ raw Cdecomp_expert_collisionevents.c(0x40E6A0) — Expert board events ✅ raw Cdecomp_scene_setcamera.c(0x419FA0) — camera system ✅ raw Cdecomp_scene_render.c(0x41A2E0) — render dispatch (comment-only header)decomp_ball_vtable_0x408390.c— ball list iteration ✅ raw Cdecomp_ball_vtable_0x409480.c— P2 ball list access ✅ raw CSCENE_SYSTEM_DECOMP.md— vtable map, render pipeline, camera systemBALL_OBJECT_MODDING.md— ball structs for cross-referencescene_struct.h— partial C struct from dtor analysis
Document generated: 2026-06-06
Method: Ghidra decompilation + cross-reference with existing scene docs + automated offset extraction from 29 raw decomp files
Confidence: High for ✅ verified offsets, Medium for ⚠️ comment-only offsets, Low for ❓ inferred fields
🔗 Related Documents
Scene Struct Deep Dive
Scene Struct Deep Dive
Overview
The Scene struct is the central game object that holds all runtime state for a level.
It's created during level loading and contains references to all objects, physics state,
camera configuration, input handling, and scene graph data.
Scene Struct Layout (partial, from decompilation)
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x00 | vtable* | vtable | Scene vtable (0x4D0260) |
| +0x04 | App* | app | Application pointer |
| +0x08 | Scene* | scene | Self-reference (circular) |
| +0x0C-0x14 | fields | - | Reserved / flags |
| +0x18 | AthenaList | gadget_list | Main gadget list |
| +0x1C | int | gadget_count | Number of gadgets |
| +0x20-0x28 | int[] | gadget_iters | Iterator indices |
| +0x42C | AthenaList | - | Secondary list |
| +0x424 | AthenaList* | data_ptr | Data array pointer |
| +0x540 | void* | bonk_popup | Bonk popup object pointer |
| +0x5410 | void* | bridge1_mesh | BreakBridge 1 mesh |
| +0x5414 | void* | bridge2_mesh | BreakBridge 2 mesh |
| +0x5418 | BreakBridge* | bridge1 | Active BreakBridge 1 |
| +0x541C | BreakBridge* | bridge2 | Active BreakBridge 2 |
| +0x5420 | void* | popcyl_mesh | PopCylinder mesh |
| +0x5428 | AthenaList | popcyl_list | PopCylinder list |
| +0x5840 | void* | blockdawg1_path | "DAWGPATH1" path |
| +0x5844 | void* | blockdawg2_path | "DAWGPATH2" path |
| +0x584C | AthenaList | catapult_list | Catapult list |
| +0x607C | void* | gluebie_mesh | Gluebie mesh |
| +0x6080 | AthenaList | gluebie_list | Gluebie list |
| +0x858 | AthenaList | objects | All scene objects |
| +0x864 | void* | active_input | Currently active input gadget |
| +0x870 | int | input_state | Input state flags |
| +0x878 | App* | app_ptr | Application pointer (alternate) |
| +0x87C | SoundChannel* | sound | Level sound channel |
| +0x8AC | LevelMesh* | level_mesh | Level mesh data |
| +0x8B4 | int | object_count | Incremented on spawn |
| +0x2578 | AthenaList | all_objects | Master object list (all types) |
| +0x29BC | float | orbit_angle | Camera orbit angle |
| +0x29C0 | float | orbit_distance | Camera orbit distance |
| +0x3F1C | int | path_active | Camera path rail flag |
| +0x3F20 | Path* | path_obj | Camera path object |
| +0x3F24 | float | path_t | Path parameter (0-1) |
| +0x3F28 | int | snap_flag | Camera snap flag |
| +0x3F34 | Vec3 | snap_position | Camera snap target |
| +0x3F38 | int | snap_flag2 | Alternate snap flag |
| +0x3F2C | int | snap_countdown | Camera snap countdown (frames) |
| +0x434C | Vec3 | cam_offset | Camera offset from ball |
| +0x43AC | Vec3 | cam_initial | Camera initial position (from CAMERALOOKAT) |
| +0x43BC | Vec3 | cam_target | Camera target position (from CAMERALOOKAT) |
| +0x43C0 | float | cam_height | Camera height (800 default) |
| +0x43C4 | float | cam_distance | Camera distance (800 default) |
| +0x43C8 | float | cam_max_height | Camera max height |
| +0x43CC | int | cam_mode | Camera mode (1=orbit) |
| +0x4344 | char* | music_path | Level music path |
| +0x436C | void* | bridge_mesh | Bridge mesh reference |
| +0x4370 | int | bridge_collision | Bridge collision flag |
| +0x437C | Vec3 | bridge_pos | Bridge position |
| +0x4394 | void* | tipper_mesh | Tipper mesh |
| +0x4398 | void* | tipper_visual | TipperVisual mesh |
| +0x710 | void*[8] | render_passes | 8 render pass slots |
Key Scene Functions
| Address | Function | Description |
|---|---|---|
| 0x419FA0 | Scene_SetCamera | 5-mode camera system |
| 0x41C5B0 | Scene_SpawnBallsAndObjects | Ball creation + object spawning |
| 0x40ACA0 | Level_SelectCameraProfile | Camera profile per level |
| 0x45E0E0 | Scene_RenderAllObjects | Render all scene objects |
| 0x4692F0 | Scene_HandleInput | Dispatch input to gadgets |
Scene Creation Flow
1. LoadRaceData (0x40A120)
└─> MeshWorld_ctor: Parse MESHWORLD file
└─> Level_InitScene (0x40B090)
├─ Create SoundChannel (0x80 bytes)
├─ Set volume (-50 dB)
├─ Level mesh reset
├─ Set random ambient colors
├─ Find "CAMERALOCUS" / "CAMERALOOKAT"
├─ Set camera path (orbit mode)
├─ Level_SelectCameraProfile
└─ Play level music (2x speed / 4x intro)
2. Scene_SpawnBallsAndObjects (0x41C5B0)
├─ Create Ball(s) with start positions
├─ Scan for SAFESPOT/SAFEPOS objects
├─ CreateBadBall (tournament only)
├─ CreateMouseTrap (tournament only)
├─ CreateSecretObjects
├─ Scene_CreateFlags (goal markers)
├─ Scene_CreateSigns (direction arrows)
└─ Scene_CreateDynamicObjects
3. CreateLevelObjects (0x4121D0)
├─ BRIDGE → bridge mesh reference
├─ TIPPER → Tipper object + TipperVisual
├─ BONK → Bonk popup
├─ BBRIDGE1/2 → BreakBridge
├─ POPCYLINDER → PopCylinder
├─ BLOCKDAWG1/2 → Blockdawg (with path)
├─ CATAPULT → Catapult
└─ GLUEBIE → Gluebie sticky trap
🔗 Related Documents
Scene System Deep Documentatio
Scene System Deep Documentation
Overview
The Scene system is the core game state manager for Hamsterball. Each game mode
(menus, arenas, tournament) has its own Scene instance with a vtable at 0x4D0260.
Scene controls the game tick, rendering, camera, physics pipeline, and object lifecycle.
Key Addresses
| Function | Address | Purpose |
|---|---|---|
| Scene_Update | 0x419C00 | Main game tick (called each frame) |
| Scene_Render | 0x41A2E0 | Render dispatch (1P/2P split) |
| Scene_SetCamera | 0x419FA0 | Camera positioning and tracking |
| Scene_dtor | 0x419770 | Destructor (cleanup order reference) |
| Scene_AddObject | 0x469990 | Add SceneObject to scene |
| Scene_CreateGameOverMenu | 0x40A920 | Pause/quit menu creation |
| Gear_AdvanceAlongPath | 0x418930 | Spline path follower with collision avoidance |
Scene Struct Layout (Key Offsets)
Offsets in hex (byte addresses), computed from Ghidra int-indexed fields.
Core State
+0x0000 vtable_ptr (Scene vtable at 0x4D0260)
+0x0004 scene_base (inherited fields from base class)
+0x0878 scene_manager (D3D device/container, device at +0x174)
+0x087C viewport_obj (D3D viewport interface)
+0x3620 tick_count (frame counter, incremented in Scene_Update)
Demo Timer
+0x4358 demo_timer_active (bool)
+0x435C demo_countdown (int, counts down each frame)
+0x4360 demo_accumulator (float, popup timing)
+0x4364 demo_frame_counter
+0x4368 demo_menu_suppressed (bool)
Camera System
+0x434C camera_offset (Vec3: X/Y/Z offset from ball)
+0x4350 camera_offset_Y
+0x4354 camera_offset_Z
+0x3F1C path_follow_mode (bool, camera rails on spline)
+0x3F20 path_object (Path* for camera rail)
+0x3F24 path_position (float, parametric position)
+0x3F2C camera_snap_frames (int, frames until snap-to-ball)
+0x29BC camera_orbit_angle (float, Y-axis rotation)
+0x29C0 camera_distance (float, orbit distance)
Player/Ball Tracking
+0x0870 app_ptr (back-pointer to App singleton)
+0x2190 ball_positions_dirty (bool flag)
+0x2194 ball_list_iterator
+0x2198 ball_list_count
+0x219C ball_list_current
+0x2DE0 ball_list_array (Ball** array)
+0x2DC0 ball_count_alt
Object Lists
+0x0884 rumble_timer_1 (ArenaBoard)
+0x0898 rumble_timer_2 (ArenaBoard)
+0x08B8 scene_object_list (AthenaList<SceneObject*>)
+0x08BC scene_object_count
+0x0CC4 scene_object_array
+0x362C player_list (AthenaList of player viewports)
+0x3630 player_count (0=none, 1=single, 2=split)
+0x3A38 player_ball_array (Ball** array, indexed by player)
+0x29D0 current_ball_ptr (Ball being tracked by camera)
Rumble/Haptics
+0x3A4C rumble_active (bool)
+0x29B8 rumble_intensity (int, starts -800, decays by -10/frame)
Physics Pipeline vtable Calls
Called from Scene_Update via vtable when not in single-gear mode:
vtable[0x4C] = Scene_HandleRaceEnd (0x41B130) - check race finish conditions
vtable[0x50] = Scene_UpdateBallsAndState (0x41B540) - ball physics + respawn + waypoints
vtable[0x54] = NoOp (0x40A040) - stubbed (collision handling is in Ball vtable)
vtable[0x58] = Scene_ProcessRaceEnd (0x41A540) - race countdown timer
vtable[0x7C] = Scene_LevelObjUpdate (0x41AC70) - level object tick (traps, moving platforms)
Level vtable Entries (from Scene vtable map)
Level_UpdateAndRender (0x40B600, vtable[0x64])
Main level render: two-pass system (opaque + alpha).
- Clear visible_object_list, append player balls
- SetRenderState(ALPHA_BLEND=FALSE) → opaque pass
- For each ball: ball->vtable0x1C = RenderOpaque
- SetRenderState(ALPHA_BLEND=TRUE) → alpha pass
- If race active: update waypoint arrow + render
- For each visible object: obj->vtable0x08 = Render
- Ball_RenderShadow for each ball (if shadow data exists)
Scene offsets: +0x29D4=P1 balls, +0x3204=P2 balls, +0x3A48=visible objects, +0x361C=waypoint
Level_RenderObjects (0x40B570, vtable[0x68])
Transparent pass. Graphics_BeginFrame, scene_manager->vtable[0x4C]
for level geometry, then obj->vtable0x0C = RenderTransparent per visible object.
Level_RenderDynamicObjects (0x40B420, vtable[0x60])
Sky/dome + water ripples. If is_skydome_enabled (+0x3A44):
sky_dome_mesh->vtable0x48, else level_mesh->vtable0x48.
Then iterate ripple_list (+0x2160), render each:
Gfx_SetPositionAndRender→ScaleX→ScaleZ→SetPosition→FlagWaver_Render.
Finally dynamic_object->vtable8 callback.
Scene vtable Full Map (0x4D0260)
[0x00] 0x425020 Scene_ctor
[0x04] 0x419C00 Scene_Update (main game tick)
[0x08] 0x41A2E0 Scene_Render (1P/2P dispatch)
[0x0C] 0x4692F0 Scene_dtor_thunk
[0x10] 0x469220 Scene_Release
[0x14] 0x4130A0 Scene_SetupLevel
[0x18] 0x469280 Scene_SceneObjThunk1
[0x1C] 0x409D90 Scene_Init
[0x20] 0x40B400 Scene_RunTick
[0x24] 0x44B840 Scene_NoOp1
[0x28] 0x44B840 Scene_NoOp2
[0x2C] 0x4692A0 Scene_SceneObjThunk2
[0x30] 0x4692A0 Scene_SceneObjThunk3
[0x34] 0x44B840 Scene_NoOp3
[0x38] 0x409DA0 Scene_LoadLevel
[0x3C] 0x469430 Scene_SceneObjThunk4
[0x40] 0x419740 Scene_CleanupScene
[0x44] 0x4692B0 Scene_SceneObjThunk5
[0x48] 0x40B090 Scene_StartRace
[0x4C] 0x41B130 Scene_HandleRaceEnd
[0x50] 0x41B540 Scene_UpdateBallsAndState
[0x54] 0x40A040 Scene_NoOp_Collision (stub)
[0x58] 0x41A540 Scene_ProcessRaceEnd
[0x5C] 0x409DE0 Scene_?? (0x409DE0)
[0x60] 0x40B420 Level_RenderDynamicObjects
[0x64] 0x40B600 Level_UpdateAndRender
[0x68] 0x40B570 Level_RenderObjects
[0x6C] 0x41B710 Scene_RenderOverlay
[0x70] 0x41BFD0 Scene_RenderPostEffects
[0x74] 0x40C5D0 Scene_?? (0x40C5D0)
[0x78] 0x44B840 Scene_NoOp4
[0x7C] 0x41AC70 Scene_LevelObjUpdate
[0x80] 0x41C5B0 Scene_?? (0x41C5B0)
[0x84] 0x419750 Scene_?? (0x419750)
[0x88] 0x44B840 Scene_NoOp5
[0x8C] 0x41A9A0 Scene_ComputeInputForceDirection (NOT lighting — computes 3D force from player input)
Level Objects
+0x3624 level_object_list (AthenaList of level objects)
+0x362C level_object_count
+0x3630 level_object_iter
+0x3A38 level_object_array_ptr
+0x3AFC post_update_callback_obj (vtable[1] called after all updates)
Game Tick Execution Order (Scene_Update)
1. Increment tick_count
2. Demo timer check:
- If demo_timer_active: decrement countdown
- On countdown=0: spawn "end of demo" popup via ScoreDisplay_CtorC
3. Pause/escape check:
- If not menu mode (3/4) AND not pause_suppressed:
- Check ESC key via Input_CheckKeyCombo(app, 2)
- Create GameOverMenu if pressed
4. Ball position propagation:
- If ball_positions_dirty flag set:
- Iterate ball list, call Ball_SetTargetPos for each
- Clear dirty flag
5. Gear path following:
- If exactly 1 gear object AND gear_has_active_path:
- Read camera position from app->camera_obj
- Call Gear_AdvanceAlongPath(gear, camX, camY, camZ)
6. Rumble board timers:
- Tick both ArenaBoard instances
7. Rumble intensity decay:
- If rumble_active: decay intensity from -800 toward 0 by 10/frame
8. SceneObject update+render loop:
- For each SceneObject in list:
- Call vtable[1]() = Update
- If render_flag set: Gfx_SetRenderState, then vtable[0]() = Render
9. Game state pipeline (4 vtable calls):
- Scene_HandleRaceEnd -> Scene_UpdateBallsAndState -> NoOp -> Scene_ProcessRaceEnd
- HandleRaceEnd: check if race won/time expired
- UpdateBallsAndState: per-ball physics tick + OOB respawn + waypoint progression
- NoOp: collision stub (collision is in Ball vtable update)
- HandleCountdown: race start countdown timer
- Skip if in single-gear camera-locked mode
10. Level object update:
- For each level object: vtable[0x7C]()
11. Post-update callback
Render Pipeline (Scene_Render)
Single Player (player_count == 1)
Graphics_SetViewport(full_screen)
-> Copy ball->pos to camera target
-> Scene_SetCamera(scene, ball, apply_path=true)
-> vtable[0x60] Level_RenderDynamicObjects (sky/far plane)
-> vtable[0x64] Level_UpdateAndRender (level geometry + dynamics)
-> vtable[0x68] Level_RenderObjects (transparent/glass objects)
Graphics_SetViewport(full_screen)
-> vtable[0x70] Scene_RenderPostEffects (fade, transitions)
-> vtable[0x6C] Scene_RenderOverlay (HUD, score)
Split Screen (player_count == 2)
For each player:
Graphics_SetViewport(half_screen_for_player)
-> Scene_SetCamera(scene, player_ball, apply_path=true)
-> vtable[0x60] RenderBackground
-> vtable[0x64] RenderOpaqueObjects
-> vtable[0x68] RenderTransparentObjects
Graphics_SetViewport(full_screen)
-> vtable[0x70] RenderOverlay (shared HUD)
-> vtable[0x6C] RenderPostEffects
Camera System (Scene_SetCamera)
Camera Modes
- Default Follow: Camera positioned at ball->camera_target_pos + camera_offset
- Path Following: Camera rails along spline, springs back if ball wanders
- Camera Shake: Random position jitter when ball->camera_shake_enabled
- Camera Snap: Instant reposition over N frames (teleport transitions)
- Orbit Mode: Rotates around ball using orbit_angle (menu screens)
Path Following Algorithm
- When path_follow_mode is active:
- Get current position on spline at path_position
- Compute distance from camera to path center
- If distance > threshold: apply spring force
- Oscillation via sin wave (Wave_Sin)
- Damping factor prevents infinite oscillation
- Max pull strength: 700 units
- Blend camera position toward path center
- Update ball target position
App_Run Game Loop (0x46BD80)
while (!quit):
Sleep(0) // yield to OS
tick_interval = 1000 / target_fps
// FPS counter (1Hz update)
if (time > fps_timer + 1000):
update_fps_display()
reset frame counter
// Message pump
while (PeekMessage()):
TranslateMessage + DispatchMessage
if (quit) break
// Tick + Render
do:
elapsed = GetTickCount() - last_tick
if (elapsed < tick_interval - 5) || (skip_count > 9):
// Render only
if (time > render_deadline):
Graphics_BeginFrame()
vtable[0x24] Scene_Render // render-only
vtable[0x28] Scene_Present
vtable[0x2C] Scene_PostFrame
Graphics_PresentOrEnd()
break
// Full tick
frame_count++
Graphics_BeginFrame()
vtable[0x20] Scene_Update // full game tick
last_tick += tick_interval
if (accumulated lag > 1000ms):
last_tick = now - 1000 // prevent spiral of death
while (updates_this_frame < 1)
Additional Scene Functions
Level_UpdateAndRender (0x40B600)
6-phase render for level geometry and objects. See RENDERING_PIPELINE.md for details.
Phases: Build visible list → Opaque pass → Alpha pass → Waypoint arrow → Visible list render → Ball shadows
Level_RenderObjects (0x40B570)
Transparent pass: BeginFrame → mesh render → BeginFrame → vtable[0x0C] per object in visible_list
Scene_CheckPath (0x57EC0)
Ring topology pathfinder on 359-cell circular grid. Two walkers (forward +1, backward -1)
search from start to target. Returns 1=forward, -1=backward, 0=unreachable.
Scene_SpawnBallsAndObjects (0x41C5B0)
Level startup spawner. Creates player balls at "START%d-%d" hash table positions,
then creates level objects (flags, signs, dynamic objects, secret objects).
Ball defaults: radius=26.0, max_speed=5.0, gravity=0.5, speed_scale=0.1
Scene_RenderAllObjects (0x45E0E0) — Detailed
3-pass render with object classification into buckets:
- Pass 1 (Opaque): Objects with all alpha/decal flags=0, rendered immediately
- Pass 2 (Translucent): Objects with flag 0x860=1, alpha blend ON
- Pass 3 (Decal): Objects with flag 0x85F=1, stencil+depth bias enabled
- Post-alpha: Iterate children calling vtable0x48
Scene_RenderFrame (0x60DA0) — Detailed
Per-frame: build vertex buffers with zigzag triangle strips (0x20 bytes/vertex),
render sprites, construct font→texture via Font_RenderToTextureComplex, free temp MeshWorld
Key insight: The game uses a fixed-timestep update with variable rendering.
If the game falls behind, it caps accumulated lag at 1000ms to prevent
the "spiral of death" where too many updates cause more lag.
🔗 Related Documents
Screenshot Comparison
Hamsterball Screenshot Comparison - April 16, 2026
Test Environment
- Wine 9.0 on Linux
- Xvfb :99 (1024x768x24) — no real GPU
- MinGW cross-compiled i686-w64-mingw32
Original Game (Hamsterball.exe)
- File:
original_fullscreen.png - Result: D3DERR_NOTAVAILABLE error dialog
- Wine's D3D8 implementation requires a real GPU for the original game's
fullscreen hardware vertex processing mode
Reimplementation (hamsterball.exe)
- File:
reimpl.png - Result: D3D8 device created successfully, window shows clear color
- Our reimpl uses Windowed mode with software vertex processing fallback,
so it initializes but shows blank (purple-gray) screen because the
game loop rendering hasn't been wired up with actual geometry yet - Console logs: "[D3D8] Device created, 1024x768", "[Load] Arena-WarmUp: 17 objects",
"[Load] Ball at (184.9, 26.4, 183.4)"
API Call Comparison (from Wine traces)
Original Game D3D8 Calls
- Direct3DCreate8(D3D_SDK_VERSION)
- GetAdapterDisplayMode(D3DADAPTER_DEFAULT)
- CreateDevice(HAL, HARDWARE_VERTEXPROCESSING) → FAILS on Xvfb
- Also calls: BASS_Init, BASS_MusicLoad, DirectSoundCreate8, DirectInput8Create
Our Reimplementation D3D8 Calls
- Direct3DCreate8(D3D_SDK_VERSION) ✓
- GetAdapterDisplayMode(D3DADAPTER_DEFAULT) ✓
- CreateDevice(HAL, HARDWARE_VERTEXPROCESSING) → fails
- CreateDevice(HAL, SOFTWARE_VERTEXPROCESSING) → succeeds ✓
- SetRenderState calls (ZENABLE, ZWRITEENABLE, LIGHTING, AMBIENT, SHADEMODE, etc.) ✓
- SetLight(0, directional) ✓
- DirectInput8Create → keyboard + mouse ✓
- DirectSoundCreate8 → fails gracefully (non-fatal) ✓
Next Steps
- Run on real GPU (not Xvfb) for full rendering comparison
- Add Wine D3D8 call tracing wrapper (WINEDEBUG=+d3d8) to capture exact call sequence
- Compare our CreateDevice params, SetRenderState sequence, and SetTransform calls
- Implement MESHWORLD geometry rendering to fill the blank screen
🔗 Related Documents
SpeedCylinder Global Spawner
SpeedCylinder Global Spawner
Overview
Spawns a SpeedCylinder (Pendulum) at Player 1's position in any level, not just Level 6 (Up). Pure CE AutoAssembler script — no bass.dll proxy required.
Files
SpeedCylinderSpawn.CEA— CE AutoAssembler script (pure CEA, no Lua)
Installation
- Copy
SpeedCylinderSpawn.CEAto your Cheat Engine scripts folder - In CE: File → Load → select
SpeedCylinderSpawn.CEA - Enable the script
Usage
- Add the address
SpawnFlagto your CE address list (Advanced → Add Address, or type "SpawnFlag" in the address bar after enabling the script) - Enter a race or arena (any level works)
- Set
SpawnFlagto1— a SpeedCylinder spawns at Player 1's current position - The flag auto-resets to
0after spawning - Set to
1again to spawn another
How It Works
The script hooks Scene_UpdateBallsAndState (0x41B540) which runs every frame. When SpawnFlag is set:
- Checks if a SpeedCylinder mesh is already loaded (Board+0x4788, only set on Up Race)
- If not, loads the mesh from
levels\levelup-speedcylinderviaMeshWorld_ctor - Finds Player 1's ball in the all_balls_list and reads its position
- Allocates a Pendulum struct (0x150C bytes) and calls
Pendulum_ctor - Registers the new SpeedCylinder in the Board's mechanical objects list (Board+0x2578)
Verified Addresses (Ghidra, 2026-06-22)
| Address | Function | Convention |
|---|---|---|
| 0x41B540 | Scene_UpdateBallsAndState | __thiscall (ECX=Board) |
| 0x4BA57B | operator_new | __cdecl (RET) |
| 0x461510 | MeshWorld_ctor | __thiscall (RET 0x8, 2 params) |
| 0x436A20 | Pendulum_ctor | __thiscall (RET 0x18, 6 params) |
| 0x453810 | AthenaList_Append | __thiscall (RET 0x4, 1 param) |
| 0x4D1140 | "levels\levelup-speedcylinder" | String constant |
| 0x5341E0 | App global pointer | Runtime global |
🔗 Related Documents
Startup API Replication Spec
Startup API Replication Spec
This document maps the original Hamsterball.exe startup sequence to our
open-source reimplementation. Each step is verified against Ghidra
decompilation of App_Initialize_Full (0x429530).
Original Flow: WinMain (0x4278E0)
WinMain → App_Initialize_Full → App_Run → App_Shutdown
App_Initialize_Full Steps (0x429530)
| Step | Original | Our Equivalent | Status |
|---|---|---|---|
| 1 | App_Initialize (0x46BB40) — 12 sub-steps: Registry, Window, DirectInput8, BASS audio, Graphics | SDL_Init, window, GL context, audio, input | ✅ |
| 2 | Set windowed mode flag (App+0x174->0x7d1=1) | SDL_WINDOW_RESIZABLE | ✅ |
| 3 | LoadCursorA(hInst, "BLANKCURSOR") | SDL_ShowCursor(0) during gameplay | ✅ |
| 4 | SetDisplayMode(800, 600) via vtable[0x8c] | SDL_CreateWindow 800x600 | ✅ |
| 5 | D3D device check + SetRenderState(D3DRS_LIGHTING, TRUE, SHADEMODE=3) | glEnable(GL_LIGHTING), glShadeModel(GL_SMOOTH) | ✅ |
| 6 | Graphics_FindOrCreateTexture("shadow.png") | texture_load("shadow") | ✅ |
| 7 | MusicChannel_LoadAndAppend("music\music.mo3") | Mix_LoadMUS (placeholder) | ⏳ |
| 8 | LoadJukebox("jukebox.xml") | TODO: parse jukebox.xml | ⏳ |
| 9-10 | RegKeyList_CopyFromSibling (music config channels) | N/A for open-source | ⬜ |
| 11 | Continue music setup | N/A | ⬜ |
| 12 | RegKey_Open | config_load() | ⏳ |
| 13 | RegKey_ReadDword("PlayCount") default=20 | config_defaults() | ⏳ |
| 14 | Set initialized flag (App+0x200=1) | g_state = GAME_STATE_MENU | ✅ |
| 15-16 | InputDevice(0, type=1) → keyboard | SDL keyboard state | ✅ |
| 17-18 | InputDevice(1, type=2) → mouse | SDL mouse events | ✅ |
| 19-20 | InputDevice(2, type=4) → joystick1 | SDL_JoystickOpen | ✅ |
| 21-22 | InputDevice(3, type=5) → joystick2 | SDL_JoystickOpen | ✅ |
| 23 | RegKey_Close | N/A | ⬜ |
| 25 | vtable0xa0 = ShowWindow/MainMenu | ui_render_title() | ✅ |
| 26 | — | load_assets() (our addition) | ✅ |
App_Run Game Loop (0x46BD80)
| Original | Our Equivalent | Status |
|---|---|---|
| PeekMessage → DispatchMessage | SDL_PollEvent | ✅ |
| Scene_Update | physics_update(dt) | ✅ |
| Scene_Render → BeginScene | glClear + glMatrixMode set | ✅ |
| Scene_RenderAllObjects | render_level_objects + render_ball | ✅ |
| Scene_RenderScoreHUD | ui_render_hud | ✅ |
| Graphics_PresentOrEnd → EndScene + Present | SDL_GL_SwapWindow | ✅ |
| Sleep(targetFrameTime - elapsed) | SDL_Delay | ✅ |
| Target: 33ms/frame (~30fps) | 1000/30 = 33ms | ✅ |
App_Shutdown (0x46DB10)
| Original | Our Equivalent | Status |
|---|---|---|
| BASS_Free | Mix_CloseAudio | ✅ |
| DirectInput8_Release | N/A (SDL handles) | ✅ |
| Graphics_Release | glDeleteTextures + SDL_GL_DeleteContext | ✅ |
| DestroyWindow | SDL_DestroyWindow | ✅ |
| UnregisterClassEx | SDL_Quit | ✅ |
Key Constants (from binary data section)
| Address | Name | Value | Usage |
|---|---|---|---|
| 0x4CF4C0 | FRICTION | 0.95f | Per-frame velocity damping |
| 0x4CF434 | Y_DAMP | 0.8f | Vertical bounce damping |
| 0x4CF4B8 | SPEED_FRICTION | 0.99f | Continuous velocity decay |
| Ball+0x284 | Ball radius | 35.0f (0x420C0000) | Collision sphere |
| Ball+0x188 | max_speed | 5000.0f | Velocity clamp |
Status Key
- ✅ Implemented and matching original behavior
- ⏳ Partially implemented / placeholder
- ⬜ Not yet implemented
🔗 Related Documents
Struct Definitions
Hamsterball - Struct Definitions
This directory contains C header files defining the core game structures
extracted from decompiler analysis of Hamsterball.exe. These are also applied
as Ghidra data types for improved decompilation.
Ghidra Structs (Applied)
| Struct | Size | Fields | Source |
|---|---|---|---|
| Ball | 3148 bytes | 38 named fields | Ball_ctor2, Ball_Update, Ball_dtor2 |
| Vec3 | 12 bytes | 3 float fields | Vec3_Init |
| App | 2328 bytes | 22 fields | App_Initialize_Full, App_Run |
| Gadget | 2156 bytes | 16 fields | Gadget_ctor |
| SceneObject | 212 bytes | 15 fields | SceneObject_ctor |
| ArenaBoard | 16 bytes | 4 fields | ToggleTimer_Init |
| ArenaBoardObj | 18380 bytes | 6 fields | ArenaBoard_ctor |
C Header Files
| File | Struct | Description |
|---|---|---|
ball_struct.h |
Ball | Player ball physics object (~0xC98 bytes) |
gadget_struct.h |
Gadget | Base class for scene objects (0x870 bytes) |
scene_struct.h |
Scene | Main game state container (~0x1000 bytes) |
board_struct.h |
Board | Level/board state (inherits Gadget+Scene, ~0x4368) |
app_struct.h |
App | Global app singleton (g_App at 0x4FD680) |
rumbleboard_struct.h |
ArenaBoard | Haptic feedback timer (0x14 bytes) |
Inheritance Hierarchy
SceneObject (vtable 0x4D934C) - base 3D object with position/rotation/scale
└─ Gadget (+0x000, vtable 0x4D9170, size 0x870) - adds lists, app_ptr, name
└─ Board (+0x870 base, vtable 0x4D0260, size ~0x4368) - level state
└─ ArenaBoard (vtable 0x4D1358, size ~0x47D4) - rumble mode board
Ball (vtable 0x4CF3A0, size 0xC98) - player ball (NOT in Gadget hierarchy)
- Created by Board_ctor, stored at Board+0x361C
Key Vtables
| Name | Address | Functions |
|---|---|---|
| Ball | 0x4CF3A0 | 9 methods (+0x27F0, +0x5100, +0x2DE0, +0x2A70, +0x8390, +0x1590, +0x2650, +0x2C10, +0x9480) |
| Scene/Board | 0x4D0260 | Scene_DeletingDtor |
| SceneObject | 0x4D9170 | SceneObject_ScalarDtor (Gadget base) |
| SceneObject | 0x4D934C | SceneObject_dtor (standalone) |
| ArenaBoard | 0x4D1358 | ArenaBoard_dtor |
| GameObject | 0x4CF314 | sub2_dtor (Ball ctor initial vtable) |
Ball Field Map (Key Offsets)
| Offset | Ghidra Name | Type | Value/Description |
|---|---|---|---|
| 0x000 | dwVtable | uint | 0x4CF3A0 |
| 0x008 | nCollision_result | int | collision system result |
| 0x00C | nString_timer | int | Countdown (200=show), frees display_string |
| 0x010 | dwApp_state | uint | App state ptr |
| 0x014 | dwScene | uint | Scene ptr |
| 0x018 | nPlayer_index | int | -1=AI, 0+=player |
| 0x01C | dwRender_callback | uint | Render/audio vtable |
| 0x108 | dwTimer | uint | Timer_Init target |
| 0x150 | flAccumulated_time | float | Frame delta accumulator |
| 0x158 | flPrev_pos_x/y/z | float[3] | Previous frame position |
| 0x164 | flCur_pos_x/y/z | float[3] | Current position |
| 0x170 | flVel_x/y/z | float[3] | Velocity |
| 0x188 | flMax_speed | float | 5000.0f |
| 0x18C | flSpeed_scale | float | 1.0f |
| 0x1A4 | dwCollision_mesh | uint | CollisionMesh ptr |
| 0x1A8 | flGravity_vec_x/y/z | float[3] | Gravity vector |
| 0x1B8 | dwRender_ctx_1 | uint | RenderContext 1 |
| 0x1C8 | flRender_alpha | float | 0.75f |
| 0x208 | dwRender_ctx_2 | uint | RenderContext 2 |
| 0x20C | flColor_a/r/g/b | float[4] | RGBA (1.0f init) |
| 0x254 | bUses_alpha | byte | color_a != 1.0f |
| 0x264 | pRumble_timer1 | byte[20] | ArenaBoard timer 1 |
| 0x278 | flGravity_scale | float | 0.1f |
| 0x281 | bUnused_init_flag | byte | 1=init (DEAD: never read) |
| 0x284 | flRadius | float | 27.0f |
| 0x290 | pRumble_timer2 | byte[20] | ArenaBoard timer 2 |
| 0x2A4 | - | float | 5.0f |
| 0x2FC | flTimer_bf | float | 1.0f, countdown |
| 0x768 | bCam_active | byte | Camera follow on |
| 0x764 | flCam_follow | float | 1.0f cam lerp |
| 0xC28 | dwDisplay_string | uint | Timer string ptr |
| 0xC3C | bTeleport_active | byte | Teleport flag |
| 0xC40 | flTeleport_x/y/z | float[3] | Teleport destination |
Applying Structs
The create_struct MCP tool requires JSON format for the fields parameter:
[{"name": "field_name", "offset": 0, "type": "uint"}]
Ghidra ECX this parameters cannot be retyped via API. Plate comments document intended types.
To fully apply: manually retype this in Ghidra GUI (right-click -> Retype Variable -> Ball*).
🔗 Related Documents
Structures and Types
Hamsterball - Structures and Types
Game Data Types (INFERRED from binary and asset analysis)
3D Vector Types
// CONFIRMED - observed in MESH and MESHWORLD files
typedef struct {
float x, y, z;
} Vec3;
typedef struct {
float x, y, z, w; // w often = 1.0 or 0.0
} Vec4;
typedef struct {
float m[3][3]; // or m[4][3] with padding
} Matrix3x3;
MESH File Header (HIGH CONFIDENCE)
// Based on analysis of Sphere.MESH, Bell.MESH, Hamster.MESH
typedef struct {
uint32_t version; // Always 1
uint32_t name_length; // Length of mesh name string
char name[]; // Null-terminated, padded to name_length
// Followed by transform/material data
} MeshHeader;
// After header, per-object data:
// Position: Vec3 (3 floats)
// Scale: Vec3 (3 floats, typically 1.0)
// Bounding sphere/box data
// Material properties:
// - Diffuse color: Vec3 (RGB floats)
// - Ambient color: Vec3 (RGB floats)
// - Specular color: Vec3 (RGB floats)
// - Specular power: float (typically 25.0)
// Texture name: length-prefixed string (e.g., "HamsterBall.png")
// Then vertex/index data
MESH Vertex Data (CONFIRMED from string references)
*MESH_NUMVERTEX - vertex count
*MESH_VERTEX - vertex position data
*MESH_NUMTVERTEX - texture vertex count
*MESH_TVERT - texture coordinate data
*MESH_NUMFACES - face count
*MESH_FACE - face indices
*MESH_FACENORMAL - face normals
*MESH_VERTEXNORMAL - vertex normals
These are 3DS Max ASE format strings — the MESH binary format contains
equivalent data in binary form.
MESHWORLD Level Format (HIGH CONFIDENCE)
// Based on Arena-SpawnPlatform.MESHWORLD and Arena-Beginner.MESHWORLD analysis
typedef struct {
uint32_t object_count; // Number of objects in level
// Followed by object_count objects
} MeshWorldHeader;
// Each object starts with:
// uint32_t type_string_length;
// char type_string[type_string_length]; // Null-terminated/padded
// Known types: START1-1, START2-1, START2-2, FLAG02, FLAG04,
// FLAG06, FLAG07, SAFESPOT, CAMERALOOKAT, PLATFORM,
// N:SINKPLATFORM, E:NODIZZY<TIME>50</TIME>
//
// Position data: Vec3 (3 floats)
// Orientation: Vec3 (3 floats, Euler angles or direction)
// Scale: float (1.0 = normal)
// Additional per-type data:
// - PLATFORM: texture name, mesh reference, physics properties
// - CAMERALOOKAT: position, look-at target
// - FLAG: position, checkpoint type
// - SAFESPOT: position, safe zone properties
// Object types with embedded XML properties:
// N:SINKPLATFORM = platform that sinks after player steps on it
// E:NODIZZY<TIME>N</TIME> = disables dizzy effect for N seconds
// E:GROWSOUND = sound trigger for growth effect
Game Configuration (HS.CFG) - SPECULATIVE
// Based on hex analysis of HS.CFG (1300 bytes)
// Fixed-size records
typedef struct {
char name[64]; // Null-padded player name
float scores[?]; // High scores per race
int32_t flags[?]; // Unlock flags
} PlayerRecord;
// File appears to contain 5 player slots:
// "R. FINK", "SQUEAKS", "HAMMSTURABBI", "PEEPUMS", "MR. RAPTIS"
Race Configuration (RaceData.xml) - CONFIRMED
<!-- Per-race timing data -->
<RACENAME>
<TIME>time_limit_seconds</TIME>
<PAR>par_time_seconds</PAR>
<WEASEL>weasel_time</WEASEL> <!-- Best possible time -->
<GOLD>gold_time</GOLD> <!-- Gold medal threshold -->
<SILVER>silver_time</SILVER> <!-- Silver medal threshold -->
<BRONZE>bronze_time</BRONZE> <!-- Bronze medal threshold -->
<CAM>camera_param</CAM> <!-- Camera behavior parameter -->
</RACENAME>
Music Configuration (Jukebox.xml) - CONFIRMED
<SONG>
<NAME>display_name</NAME>
<HEX>mo3_position_hex</HEX> <!-- Pattern position in MO3 file -->
</SONG>
Font Description Format - CONFIRMED
typedef struct {
uint32_t version; // = 1
uint32_t height; // e.g., 111 (for ShowcardGothic72)
uint32_t base_char; // First character code (e.g., 32 = space)
uint32_t texture_count; // Number of texture pages
// ... per-glyph entries with:
// char_code (uint32)
// x_position (float) // X in texture atlas
// width (float) // Glyph width in texture
// x_offset (float) // Horizontal offset
// y_offset (float) // Vertical offset
// advance (float) // X advance
// texture_index (uint32) // Which texture page
} FontDescription;
Import Table Structures (Addresses)
D3D8 (d3d8.dll):
Direct3DCreate8 @ IAT 0x4CF050
DINPUT8 (DINPUT8.dll):
DirectInput8Create @ IAT 0x4CF29C
DSOUND (DSOUND.dll):
DirectSoundCreate @ IAT 0x4CF01C (ordinal import)
BASS (BASS.dll):
BASS_Init @ IAT 0x4CF04C (string ref 0x4D9238 -> string at VA 0x4D9238)
BASS_Stop @ IAT 0x4CF050 (same as D3D8 entry area?)
BASS_Free
BASS_Start
BASS_SetConfig
BASS_ErrorGetCode
BASS_MusicLoad
BASS_MusicPlayEx
BASS_ChannelSetAttributes
BASS_ChannelStop
Key Global Addresses (from string cross-references)
| Address (VA) | Description | Confidence |
|---|---|---|
| 0x4D9384 | App::Initialize debug strings | CONFIRMED |
| 0x4D8A98 | Graphics::Initialize debug strings | CONFIRMED |
| 0x4D88A8 | Graphics::Defaults debug strings | CONFIRMED |
| 0x4D0634 | BoardLevel3::BoardLevel3 debug strings | CONFIRMED |
| 0x4D5DB0 | "BallPath" string | CONFIRMED |
| 0x4D8708 | "HAMSTERBALL TOURNAMENT!" string | CONFIRMED |
| 0x4D3EAC | "CLICK HERE TO PLAY!" string | CONFIRMED |
| 0x4D03D0 | "RACE TIME:" string | CONFIRMED |
| 0x4D2659 | "DATA\HS.CFG" path string | CONFIRMED |
| 0x4D8F9E | "%s.mesh" format string | CONFIRMED |
SceneObject Structure (vtable 0x4D934C, CONFIRMED from Ghidra decompilation)
// Size: 0xD4 (212 bytes)
// Constructor: SceneObject_ctor at 0x46B4F0
typedef struct {
void* vtable; // +0x000 - Always 0x4D934C
int gfxContext; // +0x004 - Graphics context pointer (param_1 of ctor)
int field_08; // +0x008 - Zeroed
int field_0C; // +0x00C - Zeroed
int field_10; // +0x010 - Zeroed
int field_14; // +0x014 - Zeroed
int field_18; // +0x018 - Zeroed
int field_1C; // +0x01C - Zeroed
// +0x20 to +0x87: padding / unknown
char visible; // +0x088 - Visibility flag (1=visible, 0=hidden)
int zOrder; // +0x08C - Z-order / object ID (-1 = unregistered)
float baseScale[5]; // +0x094 - 4x4 base scale matrix (initialized to identity 1.0,1.0,1.0,1.0)
float rotation[5]; // +0x0A8 - 4x4 rotation matrix (initialized to zero)
float worldMatrix[5]; // +0x0BC - 4x4 world transform matrix (initialized to zero)
float radius; // +0x0CC - Bounding radius = sqrt(global_constant)
int type; // +0x0D0 - Object type (3 = default)
} SceneObject; // Total: 0xD4
// Scene registration:
// Scene_RegisterObject (0x453BD0): obj->zOrder = id; obj->vtable[3](); scene[0x710 + id*4] = obj;
Vec3 Structure (vtable 0x4CF300, CONFIRMED)
// Size: 20 bytes (0x14)
// Constructor: Vec3_Init at 0x453180
typedef struct {
void* vtable; // +0x000 - 0x4CF300
float x; // +0x004
float y; // +0x008
float z; // +0x00C
float w; // +0x010 - Default 1.0 (0x3F800000)
} Vec3;
Scene Object Offsets (CONFIRMED from Scene_dtor and Scene_Update)
// Scene object (large, ~0xFC8+ bytes based on dtor iteration)
// Scene_dtor at 0x419770 shows these lists:
#define SCENE_STATIC_OBJECTS 0x22E // Static objects (persistent, updated each frame)
#define SCENE_DYNAMIC_OBJECTS 0x335 // Dynamic objects (moving platforms etc.)
#define SCENE_SUB_OBJECTS 0x43B // Sub-objects (linked to dynamic, removable)
#define SCENE_LEVEL_ORIG 0x22B // Original Level object pointer
#define SCENE_LEVEL_CLONE 0x22C // Cloned Level object pointer
#define SCENE_RESOURCE1 0x21F // Resource pointer 1
#define SCENE_RESOURCE2 0xE92 // Resource pointer 2
#define SCENE_BALL_LIST 0xA75 // Ball/character list (position updates)
#define SCENE_EXTRA_LIST 0xC81 // Additional object list
#define SCENE_RENDER_LIST 0xD8B // Render update list
// Scene_Update at 0x419C00:
#define SCENE_FRAME_COUNTER 0xD88 // Incremented each tick
#define SCENE_GAME_STATE 0x708 // 3 = racing
#define SCENE_SCREEN_OFFSET 0xA6E // Screen offset (decrements 10/frame)
#define SCENE_DEMO_TIMER_ON 0x10D6 // Demo expiration timer active
#define SCENE_DEMO_COUNTDOWN 0x10D7 // Demo countdown frames
#define SCENE_UNPAUSE_FLAG 0x10DA // Unpause gate
Key Global Addresses (continued)
| Address (VA) | Description | Confidence |
|---|---|---|
| 0x4D8D80 | "Direct3DCreate8" import name | CONFIRMED |
| 0x4D0260 | Scene vtable (36 virtual method entries) | CONFIRMED |
| 0x4D934C | SceneObject vtable (10 virtual method entries) | CONFIRMED |
| 0x4CF300 | Vec3 vtable | CONFIRMED |
| 0x4D9368 | SceneObject secondary vtable (set by BaseDtor) | CONFIRMED |
Scene Vtable Constants
// Scene vtable at 0x4D0260 - slot offsets
#define SCENE_VT_DELETING_DTOR 0x00 // 0x425020: ~Scene + free
#define SCENE_VT_UPDATE 0x04 // 0x419C00: Scene_Update
#define SCENE_VT_RENDER 0x08 // 0x41A2E0: Scene_Render
#define SCENE_VT_HANDLE_INPUT 0x0C // 0x4692F0: Menu item input handling
#define SCENE_VT_ACTIVATE_ITEM 0x10 // 0x469220: Activate current item
#define SCENE_VT_SELECT_ITEM 0x18 // 0x469280: Select current item
#define SCENE_VT_CLEAR_ITEM 0x2C // 0x4692A0: Clear current item ptr
#define SCENE_VT_SAVE_CLEANUP 0x44 // 0x4692B0: Save and cleanup
#define SCENE_VT_INIT_SCENE 0x48 // 0x40B090: Level_InitScene
#define SCENE_VT_NOOP 0x24 // 0x44B840: Default empty stub
// SceneObject vtable at 0x4D934C - slot offsets
#define SOBJ_VT_DTOR 0x00 // 0x46B650: ~SceneObject
#define SOBJ_VT_SET_POSITION 0x04 // 0x46B490: Set position + update
#define SOBJ_VT_SET_SCALE 0x08 // 0x46B4B0: Set scale + update
#define SOBJ_VT_RENDER 0x0C // 0x46B670: Build world matrix + D3D
#define SOBJ_VT_SET_VISIBLE 0x10 // 0x46B4D0: Toggle visibility
#define SOBJ_VT_DELETING_DTOR 0x1C // 0x46B9F0: BaseDtor + free
// Scene current item pointer (used by vtable slots 3-6, 11-12)
#define SCENE_CURRENT_ITEM 0x864 // ptr to current menu/scene item
Rumble Board Arena Paths
// 15 arena level paths loaded by ArenaBoard_*_Init functions
"levels\\arena-WarmUp" // 0x413C20
"levels\\arena-beginner" // 0x413CE0
"levels\\arena-intermediate" // 0x414180
"levels\\arena-dizzy" // 0x414240
"levels\\arena-tower" // 0x4144B0
"levels\\arena-up" // 0x414960
"levels\\arena-expert" // 0x414B10
"levels\\arena-Odd" // 0x414CE0
"levels\\arena-Toob" // 0x414F00
"levels\\arena-Wobbly" // 0x4153A0
"levels\\arena-Sky" // 0x4158C0
"levels\\arena-Master" // 0x416080
"levels\\arena-neon" // 0x416F40
"levels\\arena-glass" // 0x417DF0
"levels\\arena-impossible" // 0x418540
// Tournament sub-level paths (Board subclasses)
"Levels\\Level8-Spinny" // Toob Board "Rodenthood"
"Levels\\Level8-Saw" // Toob Board "Rodenthood"
"Levels\\Level8-Fallout" // Toob Board "Rodenthood"
"Levels\\Level8-Blockdawg1" // Toob Board "Rodenthood"
"Levels\\Level8-Blockdawg2" // Toob Board "Rodenthood"
UIListItem (0x444 bytes, created by UIListItem_ctor 0x4490A0)
struct UIListItem {
char* display_text; // +0x00 (strdup'd)
char* subtext; // +0x04 (strdup'd, used as key for UIList_SetTextByName)
Vec3 position; // +0x08..0x14 (Vec3 with init) [padding/unused?]
AthenaList children; // +0x28..0x40 (AthenaList for sub-elements)
uint32_t color_r; // +0x0C*4 (red component)
uint32_t color_g; // +0x0D*4 (green component)
uint32_t color_b; // +0x0E*4 (blue component)
uint32_t color_a; // +0x0F*4 (alpha component)
SceneObject* icon; // +0x1C*4 (+0x70) — SceneObject for icon row
int linked_obj; // +0x20*4 (+0x80) — linked scene object
int height; // +0x24*4 (+0x90) — row height in pixels
AthenaList icon_list; // +0x2C*4+0x10... (icon list for sub-icons)
uint8_t is_icon_row; // +0x110 (0x441) — if 1, render as icon instead of text
uint8_t pad; // +0x441
};
SimpleMenu / UIList (vtable 0x4D6A70)
struct SimpleMenu { // extends Scene
// ... Scene base fields ...
char* name; // +0x868 ("Simple Menu")
App* app; // +0x878
Font* normal_font; // +0x87C
Font* selected_font; // +0x880
int has_header_icon;// +0x888
AthenaList items; // +0x88C (list of UIListItem*)
int item_count; // +0x890
// ... index tracking ...
int total_width; // +0xCB0
int total_height; // +0xCB4
int needs_layout; // +0xCBC (flag: 1 = re-layout needed)
UIListItem* selected_item; // +0xCC0
int scroll_pos; // +0xCCC
SceneObject* up_scroller; // +0xCA4
SceneObject* dn_scroller; // +0xCA8
int can_scroll; // +0xCD0
int scroll_up_cooldown; // +0xCD4
int scroll_dn_cooldown; // +0xCD8
};
ArenaBoard (vtable PTR 0x4D1358, extends Board extends Scene)
struct ArenaBoard {
// ... Board/Scene base fields ...
int base_score; // +0x47AC = 6000
uint8_t is_tie_breaker; // +0x47C5
uint8_t tie_active; // +0x47CC
int max_rounds; // +0x47D0 = 0x19 = 25
// Timer fields at +0x47C8 (ToggleTimer_Init/CleanupTimer/TickTimer)
// Player scores at +0x11ED..+0x11F0 (4 players)
int round_end_timer;// +0x11EB (countdown after round ends)
int game_over_flag; // +0x11F1
};
Sprite (vtable 0x4D8F84)
struct Sprite {
VTable* vtable; // +0x00 (0x4D8F84)
Graphics* gfx; // +0x04
RenderContext rc; // +0x08..0x60
Texture* texture; // +0x50
uint8_t pad; // +0x54
float width; // +0xC8 (= texture+0x14)
float height; // +0xCC (= texture+0x18)
uint8_t visible; // +0xD0 = 1
uint8_t pad2; // +0xD1
// Color/material fields: +0x60..0xC4
};
PRNG State (RNG_Rand uses this as 'this')
struct RNGState {
VTable* vtable; // +0x00
int read_ptr; // +0x04 (wraps at 55)
int write_ptr; // +0x08 (wraps at 55)
uint32_t buffer[55]; // +0x0C..0xE4 (circular buffer)
};
// Algorithm: buf[read] = (buf[read] + buf[write]) & 0x3FFFFFFF
// Return: (result >> 6) % range
// Signed mode: if param_2==1 && Rand(2)==0, negate
AthenaString Class (0x473500 family)
struct AthenaString {
char* buffer; // +0x00: pointer to char buffer
uint32_t capacity; // +0x04: allocated size (includes null terminator)
uint32_t length; // +0x08: string length (not including null)
uint32_t null_flag; // +0x0C: 1 if string was set to NULL originally
// Total: 0x10 bytes
};
// Key methods:
// - AthenaString_AssignCStr (0x473500): assigns C string, frees old, allocates new
// - AthenaString_dtor (0x4736b0): frees buffer, zeros fields, sets vtable to base
// - AthenaString_AssignCRLF (0x473a50): assigns "\r\n"
// - AthenaString_SprintfToBuffer (0x4bae43): sprintf into caller buffer
// - AthenaString_Assign (0x4737f0): full assign with formatting
// - AthenaString_Format (0x466c70): format string (98 xrefs)
// - AthenaString_Sprintf (0x4bbdfd): sprintf to internal buffer
// VTable at 0x4D290C (base dtor vtable)
🔗 Related Documents
The [[39040799821588|ball object]]
Hamsterball — The Ball Object: Complete Modding Reference
Scope: Original Hamsterball.exe (PE32, i386, Athena engine)
Last Updated: 2026-06-25
Target Audience: Modders, DLL injectors, reverse-engineers
Table of Contents
- What Is the ball object?
- Memory Layout (0xC98 bytes)
- PhysicsObject / CollisionMesh (at +0x1A4, size 0xCB0)
- Vtable Methods
- Key Modifiable Fields
- Physics Pipeline (Ball_Update)
- Collision & Events
- Rendering
- AI & Special Modes
- Modding Hook Points
- Quick Reference
What Is the Ball Object?
The Ball is the player-controlled hamsterball. It is the first parameter of Ball_Update (0x405E00) and the core physics actor in every race and arena mode. The Ball derives from GameObject and has a vtable at 0x4CF3A0 with 65+ entries (not 9 — see Vtable Methods).
Constructor chain:
Ball_ctor (0x40AFE0) → allocate 0xC98 bytes, set base vtable
Ball_ctor2 (0x4039E0) → init physics defaults, radius, gravity, timers
Scene_SpawnBallsAndObjects → place at START object, set player_index
Constructor field overrides: Ball_ctor2 sets initial defaults. Then Ball_InitPhysicsDefaults (vtable[1], 0x405100) is called during setup and overrides many fields with different values. The runtime values differ from the raw ctor2 defaults:
| Field | ctor2 value | InitPhysicsDefaults value |
|---|---|---|
| +0x188 max_speed | 5000.0 | 6.0 |
| +0x1A0 speed_scale | 1.0 | 0.2 |
| +0x278 gravity_scale | 0.1 | 0.5 |
| +0x27C (unknown) | 0.0 | 0.2 |
| +0x284 radius | 27.0 | 35.0 |
Always reference the InitPhysicsDefaults column for actual in-game defaults.
Destructor: Ball_dtor (0x4027F0) → Ball_dtor2 → GameObject_dtor → optionally free(this).
Memory Layout
Total size: 0xC98 bytes (3,224 bytes)
Base class: GameObject (vtable 0x4CF314)
Own vtable: 0x4CF3A0
Critical Fields (Modder-Useful Offsets)
| Offset | Type | Name | Default (runtime) | Description |
|---|---|---|---|---|
| 0x000 | void** | vtable | 0x4CF3A0 | Ball vtable (65+ entries) |
| 0x00C | int32 | string_timer | 0 | Countdown to free display_string |
| 0x010 | void* | app_state | — | App pointer (from param_1+0x878) |
| 0x014 | void* | scene | — | Pointer to parent Scene/Board object |
| 0x018 | int32 | player_index | -1 | -1 = AI / none, 0 = Player 1, 1 = Player 2, etc. |
| 0x014 | void* | render_callback | — | UITimer sub-object start |
| 0x150 | float | accumulated_time | 0.0 | Delta-time accumulator per frame |
| 0x154 | int32 | rng_seed | — | Random seed (set by RNG_Rand) |
| 0x158 | float | prev_pos_x | 0.0 | Previous frame X |
| 0x15C | float | prev_pos_y | 0.0 | Previous frame Y |
| 0x160 | float | prev_pos_z | 0.0 | Previous frame Z |
| 0x164 | float | pos_x | 0.0 | Current position X |
| 0x168 | float | pos_y | 0.0 | Current position Y |
| 0x16C | float | pos_z | 0.0 | Current position Z |
| 0x170 | float | force_x | 0.0 | Force accumulator X (cleared each frame, filled by ApplyForce) |
| 0x174 | float | force_y | 0.0 | Force accumulator Y |
| 0x178 | float | force_z | 0.0 | Force accumulator Z |
| 0x17C | float | accel_x | 0.0 | Acceleration X |
| 0x180 | float | accel_y | 0.0 | Acceleration Y |
| 0x184 | float | accel_z | 0.0 | Acceleration Z |
| 0x188 | float | max_speed | 6.0 | Speed cap (ctor2=5000.0, overridden to 6.0 by InitPhysicsDefaults) |
| 0x18C | float | speed_scale | 1.0 | Global speed multiplier |
| 0x190 | float | unknown_190 | -1.0 | Unknown (set to -1.0 in ctor2) |
| 0x194 | float | unknown_194 | -1.0 | Unknown (set to -1.0 in ctor2) |
| 0x19C | byte | unknown_19C | 0 | Unknown flag |
| 0x1A0 | float | speed_cap_multiplier | 0.2 | Secondary cap multiplier (ctor2=1.0, overridden to 0.2) |
| 0x1A4 | PhysicsObject* | physics_object | — | PhysicsObject (CollisionMesh) pointer, size 0xCB0 — see PhysicsObject |
| 0x1A8 | float[3] | gravity_vec | (0,1,0) | Gravity direction vector |
| 0x1B8 | — | render_ctx_1 | — | RenderContext sub-object |
| 0x1C8 | float | render_alpha | 0.75 | Render context #1 alpha |
| 0x1BC | float | render_scale_x | 0.25 | Render context scale |
| 0x1C0 | float | render_scale_y | 0.25 | Render context scale |
| 0x1C4 | float | render_scale_z | 0.25 | Render context scale |
| 0x204 | byte | render_flag_204 | 1 | Unknown render flag |
| 0x208 | — | render_ctx_2 | — | RenderContext sub-object #2 |
| 0x20C | float | color_r | 1.0 | RGBA red |
| 0x210 | float | color_g | 1.0 | RGBA green |
| 0x214 | float | color_b | 1.0 | RGBA blue |
| 0x218 | float | color_a | 1.0 | RGBA alpha |
| 0x23C | float | render2_r | 1.0 | Render context #2 RGBA |
| 0x240 | float | render2_g | 1.0 | Render context #2 RGBA |
| 0x244 | float | render2_b | 1.0 | Render context #2 RGBA |
| 0x248 | float | render2_a | 1.0 | Render context #2 RGBA |
| 0x254 | uint8 | uses_alpha | 0 | True if color_a != 1.0 |
| 0x25C | float | unknown_25C | 0.0 | Unknown (modified in Ball_Update spin friction) |
| 0x260 | uint8 | boost_hit_flag | 0 | Set on boost pad contact |
| 0x264 | uint8[0x14] | rumble_timer1 | — | ArenaBoard timer sub-object |
| 0x26C | int32 | unknown_26C | 20 | Unknown int (0x14) |
| 0x278 | float | gravity_scale | 0.5 | Gravity multiplier (ctor2=0.1, overridden to 0.5) |
| 0x27C | float | unknown_27C | 0.2 | Unknown (ctor2=0.0, overridden to 0.2) |
| 0x280 | uint8 | unknown_280 | 0 | Unknown flag |
| 0x281 | uint8 | unused_init_flag | 1 | DEAD: set to 1 in ctor, 0 on spawn; never read by any function |
| 0x284 | float | radius | 35.0 | Ball radius (ctor2=27.0, overridden to 35.0; shrunk to 13.0 on odd race shrink) |
| 0x288 | float | unknown_288 | 0.0 | Unknown |
| 0x290 | uint8[0x14] | rumble_timer2 | — | Second ArenaBoard timer |
| 0x29C | float | unknown_29C | 1.0 | Unknown (modified in Ball_Update) |
| 0x2A4 | float | spin_rate | 5.0 | Angular spin factor |
| 0x2A8 | float[3] | speed_modifier | (0,0,0) | Vec3 speed modifier (init by Vec3_Init) |
| 0x2B8 | float[3] | accel_vec | (0,0,0) | Acceleration vector |
| 0x2C0 | float | force_x_2C0 | 0.0 | Secondary force accumulator X |
| 0x2C4 | float | force_y_2C4 | 0.0 | Secondary force accumulator Y |
| 0x2C8 | float | force_z_2C8 | 0.0 | Secondary force accumulator Z |
| 0x2CC | uint8 | force_disable | 0 | If 1, Ball_ApplyForce is skipped |
| 0x2D5 | uint8 | unknown_2D5 | 0 | Unknown (cleared by timer decay) |
| 0x2D8 | int32 | unknown_2D8 | 0 | Unknown |
| 0x2DC | float | lgp_x | 0.0 | Last Grounded Position X (LGP) |
| 0x2E0 | float | lgp_y | 0.0 | Last Grounded Position Y (LGP) |
| 0x2E4 | float | lgp_z | 0.0 | Last Grounded Position Z (LGP) |
| 0x2E8 | uint8 | event_flag | 0 | Checkpoint-hit event marker |
| 0x2E9 | uint8 | impact_shatter | 0 | ⚠ NOT on_ramp/ground flag! Sticky limit/trajectory flag (E:LIMIT + type-5 collision). Never cleared within Ball_Update. See docs/agent-knowledge/ball-ground-detection.md |
| 0x2EC | int32 | unknown_2EC | 0 | Unknown |
| 0x2F0 | uint32 | force_count | 0 | Number of forces applied this frame |
| 0x2F4 | int32 | unknown_2F4 | 0 | Unknown |
| 0x2F8 | uint8 | update_in_progress | 0 | Set 1 during Ball_Update |
| 0x2F9 | uint8 | frozen | 0 | Stuck on surface (velocity zeroed) |
| 0x2FC | float | freeze_timer | 1.0 | Countdown while frozen (ctor2=1.0, not 150 as previously documented) |
| 0x300 | uint32 | freeze_val | 0 | (ctor2=0, not 150 as previously documented) |
| 0x30A | char* | display_string_ptr | NULL | Floating text string (freed when string_timer hits 0) |
| 0x30F | uint8 | teleport_flag | 0 | Teleport pending flag |
| 0x310 | uint8 | state_active | 1 | General active flag |
| 0x311 | float | teleport_x | 0.0 | Teleport destination X |
| 0x312 | float | teleport_y | 0.0 | Teleport destination Y |
| 0x313 | uint8 | unknown_313 | 0 | Camera/limit-related flag |
| 0x314 | float | ambient_sound_timer | 0.0 | Timer for ambient sound decay |
| 0x324 | uint8 | in_tube | 0 | If true, Ball_Update returns immediately — no physics! |
| 0x328 | int32 | unknown_328 | -1 | Unknown (set to 0xFFFFFFFF) |
| 0x32C | AthenaList | trail_list_32C | — | Trail particle list |
| 0x700 | int32 | sound_3d_handle | — | 3D sound effect handle |
| 0x744 | int32 | unknown_744 | 0 | Unknown |
| 0x748 | int32 | unknown_748 | 0 | Unknown |
| 0x74C | int32 | unknown_74C | 0 | Unknown |
| 0x750 | int32 | unknown_750 | 0 | Unknown |
| 0x754 | int32 | unknown_754 | 0 | Unknown |
| 0x768 | uint8 | cam_active | 1 | Camera follow enabled |
| 0x769 | uint8 | cam_flag_769 | 0 | Camera snap flag |
| 0x76A | uint8 | cam_flag_76A | 0 | Cleared on surface snap |
| 0x764 | float | cam_follow_factor | 1.0 | Camera follow lerp factor |
| 0x778-0x784 | float[4] | unknown_778 | 0.0 | Unknown camera-related fields |
| 0x788 | float[16] | matrix_2 | zeros | Second matrix (4×4, zeroed in ctor2) |
| 0x7C8 | float[16] | matrix_1 | identity | 4×4 transform matrix |
| 0x808 | float[2] | unknown_808 | (0,0) | Unknown (NOT freeze_counter — see freeze_timer at 0x2FC) |
| 0x810 | AthenaList | list_810 | — | Unknown list |
| 0xC28 | char* | display_string | NULL | Floating text above ball |
| 0x0C38 | int32 | unknown_C38 | -1 | Unknown (set to 0xFFFFFFFF) |
| 0xC3C | uint8 | teleport_active | 0 | Teleport in progress |
| 0xC40 | float | teleport_x | 0.0 | Teleport destination X |
| 0xC44 | float | teleport_y | 0.0 | Teleport destination Y |
| 0xC48 | float | teleport_z | 0.0 | Teleport destination Z |
| 0xC4C | uint8 | is_shrunk | 0 | odd race shrunk state (E:SHRINK=1, E:GROW=0) |
| 0xC50 | int32 | unknown_C50 | 0 | Unknown |
| 0xC54 | int32 | unknown_C54 | 0 | Unknown (AthenaList data pointer) |
| 0xC58 | uint8 | unknown_C58 | 0 | Unknown |
| 0xC5C | int32 | unknown_C5C | 0 | Unknown (alternate_state flag used by ApplyForce) |
| 0xC88 | float[16] | world_matrix | identity | 4×4 world transform for rendering |
Field naming note: Fields at +0x170/+0x174/+0x178 were previously documented as "vel_x/y/z". They are actually force accumulators — cleared to zero at the start of each Ball_Update frame, then populated by Ball_ApplyForce. The actual per-frame velocity is the delta between
pos(0x164) andprev_pos(0x158), computed internally by Ball_Update and stored temporarily.
Physics Object
The field at +0x1A4 is a pointer to a PhysicsObject (internally called CollisionMesh), allocated as operator_new(0xCB0) (3264 bytes) and constructed via CollisionMesh_ctor (0x456D80).
This is NOT just a collision mesh — it stores the ball's runtime physics state including gravity normal, computed velocity, collision entries, and AI parameters.
Construction
CollisionMesh_ctor(this, ball_ptr) // 0x456D80
→ Sets vtable to 0x4D8E10 (Mesh_DeletingDtor)
→ Stores ball back-reference at +0x10
→ Inits 3 AthenaLists at +0x18, +0x430, +0x848
→ Calls Ball_InitBattleMode(this) // 0x456CD0
PhysicsObject Internal Layout (size 0xCB0)
| Offset | Type | Default | Description |
|---|---|---|---|
| +0x000 | void** | 0x4D8E10 | Vtable (Mesh_DeletingDtor) |
| +0x010 | void* | ball ptr | Back-reference to owner Ball |
| +0x018 | AthenaList | — | Collision entry list (type 1=ball-ball, 2=wall, 5=floor entries) |
| +0x430 | AthenaList | — | Second collision node list |
| +0x848 | AthenaList | — | Third list (event collision objects) |
| +0xC60 | int32 | 3 | Battle mode state (3 = arena default) |
| +0xC64 | float | — | (not set in ctor) — written by Ball_Update as speed |
| +0xC68 | float | 0.555 | Battle mode friction parameter |
| +0xC6C | float | 1.0 → 600.0 | CHASE distance (overridden to 600.0 by InitPhysicsDefaults) |
| +0xC70 | float | 1000.0 → 1200.0 | HOME distance (overridden to 1200.0 by InitPhysicsDefaults) |
| +0xC74 | float | 0 | Speed value (written by Ball_SetSpeed) |
| +0xC78 | float | 25.0 → 0.0 | spin_angle (overridden to 0.0 by InitPhysicsDefaults) |
| +0xC7C | byte | 1 | Unknown flag |
| +0xC80 | float[3] | (0,0,0) | Unknown velocity vector |
| +0xC8C | float[3] | (0,-1.0,0) | Gravity normal (default points down) |
| +0xC98 | float[3] | — | Computed velocity (written every frame by Ball_Update) |
| +0xCA4 | float[3] | (0,0,0) | Direction vector (surface normal cache) |
Collision Entry Structure
Each entry in the PhysicsObject+0x18 AthenaList is a struct accessed by Ball_Update as int*. Known fields:
| Index | Byte Offset | Type | Description |
|---|---|---|---|
| [0] | +0x00 | int32 | Type: 1=ball-ball, 2=wall, 5=floor |
| [1] | +0x04 | int32 | Unknown (not read by Ball_Update — verify before using) |
| [3] | +0x0C | Ball* | Other ball (for type 1 = ball-ball collision) |
| [4] | +0x10 | Scene* | Board pointer (used for +0x434, +0x43C lookups) |
| [8] | +0x20 | float | Collision normal vector X |
| [9] | +0x24 | float | Collision normal vector Y |
| [10] | +0x28 | float | Collision normal vector Z |
| [12] | +0x30 | float | Secondary vector X |
| [13] | +0x34 | float | Secondary vector Y |
| [14] | +0x38 | float | Secondary vector Z |
| [21] | +0x54 | float | Collision depth/distance |
| [25] | +0x64 | PhysicsObject* | Owner physics object (compared against ball's physics) |
Note: The
currCollision[1] == 4check used in some mod frameworks targets offset +0x04, which is not read by Ball_Update. This field may be set during collision creation inBall_AdvancePositionOrCollision(0x4564C0), but its meaning is unverified. Test with MessageBoxA dumps before relying on it.
Vtable Methods
The Ball vtable lives at 0x4CF3A0 with 65+ entries (not 9 as previously documented). The vtable is NOT terminated at index 9 — the NULL at index 9 is a valid entry, not an end marker.
Documented Vtable Entries
| Vtable Offset | Address | Name | Description |
|---|---|---|---|
| +0x00 | 0x4027F0 | Ball_dtor | Destructor — calls Ball_dtor2, optionally frees memory |
| +0x04 | 0x405100 | Ball_InitPhysicsDefaults | Sets runtime physics defaults (overrides ctor2 values) |
| +0x08 | 0x402DE0 | Ball_CollisionCheck | Per-frame collision against level mesh + planes |
| +0x0C | 0x402A70 | Ball_OnCollision | Collision response dispatcher |
| +0x10 | 0x408390 | Ball_AI_ChaseNearest | AI opponent steering (finds nearest ball, applies force) |
| +0x14 | 0x401590 | Ball_vtable5 | Unknown (called from collision path) |
| +0x18 | 0x402650 | Ball_ApplyForceWithMultipliers | Apply directional force vector to velocity accumulators |
| +0x1C | 0x402C10 | Ball_vtable7 | Unknown (render-related) |
| +0x20 | 0x409480 | Ball_SplitAndExplode | Called on E:BREAK collision events |
| ... | ... | ... | (many more entries up to 65+) |
| +0x104 | 0x408830 | Ball_FallUpdate | Fall animation + respawn logic |
Base GameObject vtable (0x4CF314) provides shared destructor logic used before Ball-specific cleanup.
Key Modifiable Fields
Position / Velocity (Direct Write Safe)
Writing to these offsets from a DLL hook is safe during Ball_Update or Scene_UpdateBallsAndState:
// Instant teleport (no animation)
*(float*)(ball + 0x164) = newX;
*(float*)(ball + 0x168) = newY;
*(float*)(ball + 0x16C) = newZ;
// Zero force accumulators (emergency stop)
*(float*)(ball + 0x170) = 0.0f;
*(float*)(ball + 0x174) = 0.0f;
*(float*)(ball + 0x178) = 0.0f;
Note: +0x170/+0x174/+0x178 are force accumulators, NOT persistent velocity. They are cleared to zero at the start of each Ball_Update frame. The actual per-frame velocity is computed internally as
pos - prev_pos.
Physics Constants (Game Data Section)
These global floats at 0x4CF3xx affect ALL balls. Patch once, affects every frame:
| Address | Value | Effect |
|---|---|---|
| 0x4CF368 | 0.0 | Float epsilon |
| 0x4CF36C | 0.75 | Force multiplier when is_shrunk (odd race) |
| 0x4CF374 | 0.2 | Force multiplier on ice |
| 0x4CF378 | 0.0 | Force multiplier in tube (0 = no control!) |
| 0x4CF380 | 0.25 | Force multiplier after first frame of input |
| 0x4CF39C | 0.037 | Collision radius multiplier |
| 0x4CF3E8 | 6.0 | Ice friction factor |
| 0x4CF3F0 | 0.95 | Damping constant |
| 0x4CF484 | 40.0 | Collision mesh distance threshold |
| 0x4CF48C | 2.0 | Y offset threshold (radius + epsilon) |
Per-Ball Physics Overrides
| Field | Offset | Runtime Default | What It Does |
|---|---|---|---|
| radius | 0x284 | 35.0 | Collision + visual size. Shrunk to 13.0 on odd race shrink. |
| max_speed | 0x188 | 6.0 | Hard velocity cap. ctor2=5000.0, InitPhysicsDefaults=6.0. |
| speed_scale | 0x18C | 1.0 | Global multiplier on ALL velocity changes. |
| gravity_scale | 0x278 | 0.5 | Gravity strength multiplier. ctor2=0.1, InitPhysicsDefaults=0.5. |
| speed_cap_multiplier | 0x1A0 | 0.2 | Secondary cap multiplier. ctor2=1.0, InitPhysicsDefaults=0.2. |
| in_tube | 0x324 | 0 | If non-zero, Ball_Update returns immediately — no physics! |
| force_disable | 0x2CC | 0 | If non-zero, Ball_ApplyForce is skipped entirely. |
Mod idea: Setting
speed_scale = 2.0fgives a permanent speed boost. Settinggravity_scale = 0.0fmakes the ball weightless (but collision snapping still applies).
Physics Pipeline
Ball_Update (0x405E00) — The Main Tick
Called once per frame for every active ball from Scene_UpdateBallsAndState (0x41B540).
Phase 1 — Reset & Timer Decay:
ambient_sound_timer *= decay_factor
Various timer decrements (trail, boost, sound cooldowns)
string_timer decrements (frees display_string at 0xC28 when it hits 0)
Phase 2 — Trail Particles:
If trail timer active, spawn ArenaScoreParticle particle at ball position + random offset.
Phase 3 — Force Accumulator Save & Clear:
prev_pos = pos // Save current position
saved_force = force_accumulators // Copy +0x170/+0x174/+0x178
force_accumulators = (0,0,0) // Clear for next frame
Phase 4 — Spatial Tree Build:
Build a temporary SpatialTree from the scene's collision mesh for this frame's collision queries.
Phase 5 — Collision Iteration:
Iterate PhysicsObject+0x18 AthenaList entries:
- Type 1 (ball-ball): Compute collision response, apply forces, play sound, award points
- Type 2 (wall): Reflect velocity, update surface normal, apply friction
- Type 5 (floor): Set ground flag, snap to surface, trigger limit/trajectory events
Phase 6 — Spin & Roll Physics:
3 iterations of spin friction computation using gravity normal and surface velocity.
Phase 7 — Position Integration:
pos += computed_velocity // Apply final velocity
display_pos = lerp(display_pos, pos, follow_factor)
Phase 8 — Teleport Override:
If teleport_flag (0x30F) is set, override position with teleport destination.
Ball_AdvancePositionOrCollision (0x4564C0) — Core Physics
Called from the scene update pipeline. 6-phase physics:
- Free Lists — Release trail points and collision markers from last frame
- Input Velocity — Add input force to velocity, clamp to
max_speed - Damping —
velocity *= (1-dt) + (1-damping)*dt - Collision — If collision flag → vtable[0x1C] dispatch
- Gravity — Apply
gravity_vec * gravity_scalewith 0.95 damping - Trail Recording — Record trail point on non-collision frames
How to Use Ball_ApplyForce (0x402650)
Ball_ApplyForce is the primary way to externally influence ball movement. It is called from input code (Ball_GetInputForce at 0x46EC30), AI code (Ball_AI_ChaseNearest at 0x408390), and event handlers (E:TRAJECTORY, E:JUMP).
Function Signature
void __thiscall Ball_ApplyForce(
void* ball, // ECX: Ball* (this)
float force_x, // EDX: X component of force direction
float force_y, // Stack: Y component
float force_z, // Stack: Z component
float magnitude // Stack: scalar multiplier
);
What it does:
- Normalize the
(force_x, force_y, force_z)direction vector - Multiply by
magnitudeand several conditional multipliers - Accumulate the result into force accumulators at ball+0x170
- Increment
force_countat ball+0x2F0
Conditional Multipliers (Applied in Order)
| Condition | Field / Global | Multiplier | Effect |
|---|---|---|---|
force_count == 0 (first frame) |
DAT_004CF380 |
1.0 |
Full force on first press |
force_count > 0 (held) |
DAT_004CF380 |
0.25 |
Quarter force while held |
in_tube (ball+0x324) |
DAT_004CF378 |
0.0 |
Zero force — completely disabled |
on_ice flag |
DAT_004CF374 |
0.2 |
Ice: 20% force + friction 6.0 |
is_shrunk flag (ball+0xC4C) |
DAT_004CF36C |
0.75 |
Shrunk (odd race): 75% force |
alternate_state (ball+0xC5C) |
DAT_004CF374 |
0.2 |
Alternate physics mode |
| Direction tweak | DAT_004CF3E8 |
6.0 |
Scales the final direction vector |
Early-Out Checks
Ball_ApplyForce returns immediately (does nothing) if ANY of these are true:
| Condition | Offset | Value |
|---|---|---|
frozen |
ball+0x2F9 | 1 — ball is snapped to a surface |
force_disable |
ball+0x2CC | 1 — force application globally disabled |
freeze_counter |
ball+0x2FC | > 0 — input freeze timer active |
Writing Your Own Force Calls
Example 1: Push the ball forward instantly
// From a DLL hook — ball is the Ball* pointer
typedef void (__thiscall *tApplyForce)(void* ball, float fx, float fy, float fz, float mag);
tApplyForce ApplyForce = (tApplyForce)0x402650;
// Push forward (+Z) with strength 50.0
ApplyForce(ball, 0.0f, 0.0f, 1.0f, 50.0f);
Example 2: Reverse gravity burst
// Strong upward force — overrides gravity briefly
ApplyForce(ball, 0.0f, -1.0f, 0.0f, 200.0f);
// Note: -Y is "up" because gravity is +Y down
Example 3: Homing force toward a target
float dx = targetX - *(float*)(ball + 0x164);
float dy = targetY - *(float*)(ball + 0x168);
float dz = targetZ - *(float*)(ball + 0x16C);
// Normalize
float len = sqrtf(dx*dx + dy*dy + dz*dz);
if (len > 0.0f) {
dx /= len; dy /= len; dz /= len;
ApplyForce(ball, dx, dy, dz, 30.0f);
}
Forcing a Call Through the Vtable
If you want to be binary-compatible with potential mods that hook Ball_ApplyForce, call through the vtable instead of the raw address:
void** vtable = *(void***)ball; // vtable at ball+0x00
tApplyForce pfn = (tApplyForce)vtable[6]; // vtable[0x18] = ApplyForce
pfn(ball, fx, fy, fz, magnitude);
Pitfall: Writing directly to
ball+0x170(force accumulators) bypasses the multipliers, clamping, and surface-snap logic. This is fine for teleport-style hacks, but for gameplay-compatible movement, always useBall_ApplyForceso the engine handles ice, tubes, is_shrunk state, and max_speed correctly.
Ball_ApplyForceV2 (0x4016F0)
There is an alternate force application at 0x4016F0 that is gravity-plane-aware. It applies the same multipliers but respects the current gravity plane when computing facing angle. Use this if you are building a mod that supports tilted-gravity levels (Plane 1 or 2).
Collision & Events
Two-Tier Collision
Tier 1 — Planes: Ball_CheckCollisionPlanes (0x402810)
- Tests ball position against 6 collision planes stored at ball+0x0C
- Each plane:
ax + by + cz + d = 0 - Quick boundary rejection before expensive mesh tests
Tier 2 — Mesh: Mesh_FindClosestCollision (0x465D90)
- Octree traversal (
Collision_TraverseSpatialTreeat 0x465EF0) - AABB tests per triangle
- Returns closest hit point with 0.01 precision
3-Tier Event Dispatch
When the ball hits a collision object, the event string (at object+0x864) is parsed:
Scene vtable determines which handler runs:
├─ ExpertCollisionEvents (0x40E6A0) ← Rumble arenas
│ └─→ DispatchCollisionEvents (0x40C5D0) ← Shared base (ALL events)
└─ TowerCollisionEvents (0x40DCD0) ← Race levels
└─→ DispatchCollisionEvents (0x40C5D0) ← Shared base (ALL events)
Note: Arena and Level handlers are parallel, not chained. Ball_AdvancePositionOrCollision (0x4564C0) handles geometric collision only; event dispatch happens from the ball update chain.
Event prefixes:
N:= Named physical object (bumpers, trapdoors, water)E:= Event trigger (score, jump, limit, action)
Key events affecting the Ball:
| Event | Effect on Ball |
|---|---|
E:JUMP |
Bounce + 3D sound + +200pts. Sets impact=10, force=0.025. |
E:BREAK |
Calls ball vtable[0x20] (0x409480) |
E:TRAJECTORY |
Reads <X>, <Y>, <Z> XML tags, calls Ball_SetTrajectory |
N:WATER |
Sets in-water flag + 10 frame timer |
N:TARPIT |
3D sound, tar state, clears velocity |
N:GOAL |
Race finish — plays music, sets finish flags |
N:NOCONTROL |
10 frame freeze (input disabled) |
N:MOUSETRAP |
Deflects ball: normalize direction × speed |
DROPIN |
Pipe drop-in: sound + +200pts if speed > threshold |
PIPEBONK |
Random sound + +100pts, 10 frame cooldown |
POPOUT |
Sound + +100pts, 50 frame cooldown |
Ball State Used by Events
| Ball Offset | Event Using It |
|---|---|
| 0x164–0x16C (pos) | 3D sound positioning |
| 0x170–0x178 (force) | Cleared by TARPIT, modified by JUMP |
| 0x2DC–0x2E4 (checkpoint) | Ball_FindClosestRespawnPoint respawns here |
| 0xC2C (section_filter) | E:SAFESWITCH copies data here |
| 0xC4C (is_shrunk) | Set by odd race E:SHRINK/E:GROW |
Rendering
Ball_Render (0x402860)
Sets D3D8 states and draws the ball mesh:
SetRenderState(D3DRS_ZENABLE, 1)
SetRenderState(D3DRS_CULLMODE, 1)
SetRenderState(D3DRS_SPECULARENABLE, 1)
SetTexture(0, HamsterBall.png)
SetRenderState(D3DRS_LIGHTING, flag from ball+0x700)
DrawPrimitiveUP(triangle_list, vertices)
Ball_RenderShadow (0x401920)
Renders a shadow quad scaled by radius * constant, positioned at ball XYZ.
Ball_RenderWithCollision (0x402C10)
Full render pass: check collision planes → render shadow → apply scaling → end frame.
Ball_RenderWithMaterial (0x45D8F0)
Used for 3D text (Font_DrawGlyph3D). Renders ball mesh with a custom material override.
Ball_CreateTrailParticles (0x401DD0)
Spawns 9 trail particles in a ring around the ball:
- Each particle is a
ArenaScoreParticleobject (0x28 bytes) - Position = ball_pos + (radius × camera-right × sinθ) − (radius × camera-up × cosθ)
- Velocity = offset × random_scale
- Appended to scene particle list at
scene+0x3B00
AI & Special Modes
Ball_AI_ChaseNearest (0x408390)
AI steering for computer-controlled balls:
- Find nearest opponent ball (iterates ball list)
- Compute direction vector toward target
- Call
Ball_ApplyForcetoward target - If no target found: sine-wave wandering fallback
Hook tip: To make AI balls target the player, patch the nearest-ball search to always return the player ball index.
Ball_FallUpdate (0x408830)
Called when the ball falls off the track:
- Shrink radius from 35.0 → 13.0
- Handle scale change smoothly
- Clean up trail particles
- After fall animation completes, respawn at last checkpoint (0x2DC)
Ball_Shrink (0x402200)
Marks ball as fallen:
airborne = 1radius = 13.0- Play 3D sound at ball position
Ball_Grow (0x402270)
Resets from fallen state:
airborne = 0radius = 26.0(note: different from runtime default 35.0!)- Restore physics defaults
Split Ball Mechanics
| Function | Address | What It Does |
|---|---|---|
| Ball_Split_ctor | 0x408D10 | Constructor for split ball (vtable 0x4CF560) |
| Ball_Shatter | 0x408D70 | Arena 8-ball mechanic: marks parent ball for despawn, spawns 3 AI split balls (called from FollowBall_Update 0x43ECC0) |
| Ball_SplitAndExplode | 0x409480 | Creates 2 split balls + circular ArenaScoreParticle explosion (0–360°) |
Split balls are temporary physics objects that scatter from the original ball position and expire after a timer.
Ball_InitBattleMode (0x456CD0)
Initializes ball for Rodent Rumble arena mode:
- Friction = 0.555
- Bounciness = 1.0
- Radius = 400.0 (much larger than race mode!)
- Speed scale adjusted for arena physics
Modding Hook Points
High-Value Hooks for Ball Modding
| # | Target | Address | When to Hook | What You Can Do |
|---|---|---|---|---|
| 1 | Ball_Update | 0x405E00 | Every frame | Override entire physics, teleport, noclip |
| 2 | Ball_ApplyForce | 0x402650 | On input | Custom forces, reverse gravity, zero gravity |
| 3 | Ball_GetInputForce | 0x46EC30 | On input | Add custom actions (brake, jump, camera snap) |
| 4 | Ball_CollisionCheck | 0x402DE0 | Every frame | Disable collision (noclip), custom bounce logic |
| 5 | Ball_Render | 0x402860 | Every frame | Custom visual effects, wireframe, size changes |
| 6 | Ball_Shrink | 0x402200 | On odd race | Prevent shrink, teleport instead |
| 7 | Scene_UpdateBallsAndState | 0x41B540 | Every frame | Modify ball list iteration, add/remove balls |
| 8 | Ball_AI_ChaseNearest | 0x408390 | AI tick | Change AI behavior, make AI friendly/hostile |
| 9 | Scene_dtor | 0x419770 | Scene destroy | Null out cached ball/scene pointers (use-after-free prevention) |
| 10 | Board_ctor | 0x419030 | Scene create | Detect new level load, acquire scene pointer |
Scene Lifecycle Hook Pattern
For mods that cache ball/scene pointers across frames, hook both construction and destruction to avoid use-after-free:
// In Ball_Update hook (acquire pointers)
void Hooked_BallUpdate(Ball* ball) {
if (ball->player_index == 0) {
g_Player = ball;
if (g_Scene == nullptr) {
g_Scene = ball->scene; // nullptr→addr transition = new level loaded
}
}
Original_BallUpdate(ball);
}
// In Scene_dtor hook (null pointers BEFORE calling original)
void Hooked_SceneDtor(Scene* scene) {
if (scene == g_Scene) {
g_Scene = nullptr; // null FIRST, before balls get freed
g_Player = nullptr;
// g_Player2/3/4 = nullptr;
// g_Enemies.clear();
}
Original_SceneDtor(scene); // now safe — original frees balls
}
Destruction order inside Scene_dtor:
- Vtable pointer overwritten (data fields still intact)
- Ball list iterated — every ball gets
Ball_dtor(this, 1)→free() - Effect/object lists iterated and freed
- AthenaList containers freed
SceneObject_dtorbase cleanup- Returns → caller calls
free(scene)
The scene's own inline fields (camera, timers, race state) remain readable throughout, but every pointer the scene holds becomes dangling partway through. Always null your cached pointers at the top of your hook before calling the original.
Example: Speed Hack (Single Write)
// Multiply max_speed for all balls by 2x
*(float*)(ball + 0x188) = 12.0f; // 6.0 default × 2
Example: Noclip (Hook Ball_CollisionCheck)
typedef void (__thiscall *tCollisionCheck)(void* ball);
tCollisionCheck oCollisionCheck;
void __fastcall hkCollisionCheck(void* ball) {
// Do nothing — skip ALL collision
// Ball will fly through walls and floors
}
Example: Custom Action in Ball_GetInputForce
// After calling original, check your custom key
oBallGetInputForce(ball, outForce);
if (g_CustomBrakeKey.isDown) {
float* force = (float*)((char*)ball + 0x170);
force[0] *= 0.85f;
force[1] *= 0.85f;
force[2] *= 0.85f;
}
Example: Gravity Modifier
// Change gravity direction (default is +Y down)
float* gravity = (float*)((char*)ball + 0x1A8);
gravity[0] = 0.0f; // X
gravity[1] = -1.0f; // Y (upward gravity = anti-gravity!)
gravity[2] = 0.0f; // Z
Quick Reference
All Ball Functions by Address
| Address | Name | Lines | Description |
|---|---|---|---|
| 0x401590 | Ball_vtable5 | — | Unknown vtable slot |
| 0x401920 | Ball_RenderShadow | — | Shadow quad renderer |
| 0x401DD0 | Ball_CreateTrailParticles | — | 9-particle trail ring |
| 0x402200 | Ball_Shrink | — | odd race shrink ball, reduce radius |
| 0x402270 | Ball_Grow | — | Exit odd race shrink state |
| 0x402650 | Ball_ApplyForceWithMultipliers | 47 | Apply force vector to velocity accumulators |
| 0x4027F0 | Ball_dtor | — | Destructor |
| 0x402810 | Ball_CheckCollisionPlanes | — | 6-plane collision test |
| 0x402860 | Ball_Render | — | D3D8 ball mesh render |
| 0x402A70 | Ball_OnCollision | — | Collision response |
| 0x402C10 | Ball_vtable7 | — | Render-related vtable slot |
| 0x402DE0 | Ball_CollisionCheck | — | Per-frame collision entry |
| 0x4030B0 | Ball_ResetCollisionMesh | — | Reset collision state |
| 0x403100 | Ball_SetTiltedGravity | — | Set gravity plane = 1 |
| 0x403150 | Ball_SetFlatGravity | — | Set gravity plane = 2 |
| 0x4039E0 | Ball_ctor2 | — | Secondary constructor (physics init) |
| 0x405100 | Ball_InitPhysicsDefaults | — | vtable[1] — overrides ctor2 physics defaults |
| 0x405E00 | Ball_Update | 400+ | Main physics tick |
| 0x408390 | Ball_AI_ChaseNearest | 60 | AI opponent steering |
| 0x408830 | Ball_FallUpdate | 40 | Fall animation + respawn |
| 0x408D10 | Ball_Split_ctor | 14 | Split ball constructor |
| 0x408D70 | Ball_Shatter | 50 | Arena: split parent ball into 3 AI balls |
| 0x409480 | Ball_SplitAndExplode | 70 | Split + ArenaScoreParticle ring |
| 0x40AFE0 | Ball_ctor | 30 | Primary allocator + base init |
| 0x40AF90 | Ball_GetTransform | — | Read transform into struct |
| 0x4564C0 | Ball_AdvancePositionOrCollision | — | 6-phase physics pipeline |
| 0x456CD0 | Ball_InitBattleMode | — | Arena physics defaults |
| 0x456D80 | CollisionMesh_ctor | — | PhysicsObject constructor (0xCB0 bytes) |
| 0x46EC30 | Ball_GetInputForce | 7 | Convert input to force vector |
Scene Lifecycle Functions
| Address | Name | Description |
|---|---|---|
| 0x419030 | Board_ctor | Base board constructor (calls Gadget_ctor → Scene init) |
| 0x419770 | Scene_dtor | Scene destructor (frees balls, objects, lists) |
| 0x425020 | Scene_DeletingDtor | Calls Scene_dtor + free(this) |
| 0x458CE0 | Scene_Destroy | Calls Scene_ScalarDtor (alternate dtor path) |
Ball Struct C Header
A machine-readable C header is maintained at:
analysis/ghidra/structs/ball_struct.h
It contains the full 0xC98 layout with verified offsets from Ball_ctor2 decompilation.
See Also
docs/INPUT_SYSTEM.md— Input device, key remapping, DIK codesdocs/COLLISION_EVENT_SYSTEM.md— Full event dispatch chaindocs/COLLISION_SYSTEM_DEEP.md— Octree, AABB, mesh collisiondocs/MODDING_NEW_CONTROLS.md— Step-by-step DLL hook guidedocs/SCENE_SYSTEM_DECOMP.md— Scene update order, camera modesdocs/BALL_PHYSICS_DECOMP.md— Legacy physics documentationanalysis/ghidra/structs/ball_struct.h— C struct definition
Document compiled from 3,700+ documented functions, live Ghidra decompilation, and cross-referenced struct analysis. All offsets verified against PE32 Hamsterball.exe loaded at 0x00400000. PhysicsObject layout verified via CollisionMesh_ctor (0x456D80) and Ball_InitBattleMode (0x456CD0) decompilation. Runtime defaults verified via Ball_InitPhysicsDefaults (0x405100) decompilation.
🔗 Related Documents
The App Object
Hamsterball — The App Object: A Modder's Guide
What This Document Is
This is a reverse-engineering reference for anyone who wants to hook into,
read from, or modify the running Hamsterball game. The App object is the
global singleton that holds every subsystem pointer, every game-state flag,
and every config value. If you can get its address, you can reach every
other object in the game.
Getting the App Pointer
Method 1: Direct Global Address (Easiest)
The game allocates the App object as a global static in .data.
- Address:
0x004FD680(VA in Hamsterball.exe) - Symbol:
g_App - Type:
App*(pointer to a ~0xA00-byte structure)
// In your DLL / injector code
App* g_App = *(App**)0x004FD680;
This address is valid from the moment WinMain calls App_Ctor (0x0046DC40)
until process exit. The constructor runs before the game loop starts, so you
can safely read it any time after CreateWindow returns.
Method 2: Hook WinMain (Guaranteed Early Access)
If you need the address before any frame runs, hook the beginning of
WinMain (0x004278E0). The first instruction after entry is:
WinMain:
push ebp
mov ebp, esp
...
call App_Ctor ; creates App at 0x4FD680
call App_Initialize_Full
call App_Run ; game loop
call LoadOrSaveConfig
Hook address: 0x004278E0
At this point g_App is already constructed (the CRT .data init ran
before WinMain). You can read 0x004FD680 immediately.
Method 3: Hook App_Initialize_Full (All Subsystems Ready)
If you want to intercept the game after all subsystems are initialized
(Graphics, Audio, Input, Registry) but before the first frame renders:
- Address: 0x00429530
- Why: Step 25 calls
vtable[0xA0]()which shows the title screen. If
you hook after step 24 and before step 25, every subsystem pointer is
filled in.
Method 4: Hook App_Run (Per-Frame Access)
For frame-by-frame mods (trainers, overlays, TAS tools):
- Address: 0x0046BD80
- Hook point: The outer
while (!quit_flag)loop. Replace the
app->vtable[0x20]()call (Update) orapp->vtable[0x28]()call (Render)
with your own dispatcher.
// Original dispatch in App_Run:
app->vtable[0x20](); // Update — game logic
app->vtable[0x24](); // Pre-render
app->vtable[0x28](); // Render — draw everything
app->vtable[0x2C](); // Post-render / HUD
Replacing any of these four vtable calls lets you run custom code every
frame while keeping the original game running.
Method 5: Hook App_FrameUpdate (Convenient Single Point)
- Address: 0x0046C170
- What it does: Polls input, runs collision, calls
GameUpdate, handles
cursor capture. - Why hook it: One single hook gives you input, physics, and game-state
access every frame without touching the render pipeline.
App Vtable Layout
The App vtable lives at 0x004CE400. Key slots:
| Slot | Offset | Address | Name | Description |
|---|---|---|---|---|
| 0 | +0x00 | 0x46DC20 | App_ScalarDtor | Destructor + free if flag&1 |
| 2 | +0x08 | 0x46BA10 | App_Shutdown | Cleanup on exit |
| 8 | +0x20 | — | Update | Game logic (Scene_Update) |
| 9 | +0x24 | — | PreRender | Camera setup |
| 10 | +0x28 | — | Render | Draw scene |
| 11 | +0x2C | — | PostRender | HUD / menus |
| 35 | +0x8C | — | SetDisplayMode | Called with (800,600) during init |
| 40 | +0xA0 | 0x4280E0 | App_ShowMainMenu | Creates MainMenu object |
Hooking any vtable slot is the cleanest way to intercept behavior without
patching function bodies. Just overwrite the pointer at
*(void**)(g_App + 0x00) + slot_offset.
App Structure Layout (Offsets)
The App struct is the root of the game. Everything else hangs off it.
Core Identity & Window
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x000 | void** | vtable | App vtable = 0x004CE400 |
| +0x004 | HINSTANCE | hInstance | WinMain param_1 |
| +0x008 | int | cmdShow | WinMain nCmdShow |
| +0x054 | RegKey* | registryKey | Registry handle (ADVAPI32) |
Timing & Frame Control
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x05C | int | targetFPS | Target frame rate (usually 30) |
| +0x168 | int | msPerFrame | 1000 / targetFPS |
| +0x170 | int | fpsDivisor | Backup of target FPS |
| +0x18C | int | updateCount | Total physics updates |
| +0x194 | int | frameCounter | Frames rendered this second |
| +0x1AC | bool | showFPS | 1 = draw FPS counter |
| +0x210 | char* | phaseName | "Background" / "Update" / "Render" |
| +0x159 | bool | quitFlag | 1 = exit game loop |
| +0x15A | bool | activeFlag | 1 = window focused |
| +0x158 | bool | minimizedFlag | 1 = window minimized |
| +0x156 | bool | updateDisabled | 1 = pause all updates |
Subsystem Pointers (The Big Ones)
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x174 | Graphics* | graphics | D3D8 Graphics engine |
| +0x17C | AudioSystem* | audioSystem | BASS audio wrapper |
| +0x180 | InputHandler* | inputHandler | DirectInput8 handler |
| +0x184 | void* | gameUpdateObj | Passed to App_TickGameUpdate |
| +0x5D | void* | renderTarget | D3D render target surface |
Display Settings
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x15C | int | width | Window width (default 800) |
| +0x160 | int | height | Window height (default 600) |
| +0x158 | bool | windowed | 1 = windowed, 0 = fullscreen |
Audio / Music
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x534 | HMUSIC | musicHandle | BASS music handle for music.mo3 |
| +0x538 | HCHANNEL | musicChannel1 | BASS channel 1 |
| +0x53C | HCHANNEL | musicChannel2 | BASS channel 2 |
Game Mode Objects (vtable 0x8C call creates these)
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x550 | void* | gameMode1 | 1-player mode object |
| +0x554 | void* | gameMode2 | 2-player mode object |
| +0x558 | void* | gameMode3 | 4-player mode object |
| +0x55C | void* | gameMode4 | Tournament mode object |
Game State & Scenes
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x178 | Scene* | currentScene | Active scene (menu or level) |
| +0x184 | Scene* | loadingScene | Loading screen |
| +0x1DC | SoundChannel* | inputSound | Click/beep channel |
| +0x1E4 | InputDevice* | player1Device | P1 input (keyboard default) |
| +0x1E8 | InputDevice* | player2Device | P2 input |
| +0x224 | void* | mainMenuObj | MainMenu instance |
| +0x228 | void* | resultsScreen | Race results screen |
| +0x708 | int | gameState | 3 = racing, other = menu/loading |
Progress / Unlock Flags
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x851 | bool | unlock_DizzyRace | 1 = Dizzy race unlocked |
| +0x852 | bool | unlock_TowerRace | 1 = Tower race unlocked |
| +0x853 | bool | unlock_UpRace | 1 = Up race unlocked |
| +0x854 | bool | unlock_ExpertRace | 1 = Expert race unlocked |
| +0x855 | bool | unlock_OddRace | 1 = odd race unlocked |
| +0x856 | bool | unlock_ToobRace | 1 = Toob race unlocked |
| +0x857 | bool | unlock_WobblyRace | 1 = Wobbly race unlocked |
| +0x858 | bool | unlock_SkyRace | 1 = Sky race unlocked |
| +0x859 | bool | unlock_MasterRace | 1 = Master race unlocked |
| +0x85A | bool | unlock_DizzyArena | 1 = Dizzy arena unlocked |
| +0x85B | bool | unlock_TowerArena | 1 = Tower arena unlocked |
| +0x85C | bool | unlock_UpArena | 1 = Up arena unlocked |
| +0x85D | bool | unlock_ExpertArena | 1 = Expert arena unlocked |
| +0x85E | bool | unlock_OddArena | 1 = Odd arena unlocked |
| +0x85F | bool | unlock_ToobArena | 1 = Toob arena unlocked |
| +0x860 | bool | unlock_WobblyArena | 1 = Wobbly arena unlocked |
| +0x861 | bool | unlock_SkyArena | 1 = Sky arena unlocked |
| +0x862 | bool | unlock_MasterArena | 1 = Master arena unlocked |
| +0x863 | bool | unlock_NeonRace | 1 = Neon race unlocked |
| +0x864 | bool | unlock_GlassRace | 1 = Glass race unlocked |
| +0x865 | bool | unlock_ImpossibleRace | 1 = Impossible race unlocked |
| +0x866 | bool | unlock_NeonArena | 1 = Neon arena unlocked |
| +0x867 | bool | unlock_GlassArena | 1 = Glass arena unlocked |
| +0x868 | bool | unlock_ImpossibleArena | 1 = Impossible arena unlocked |
| +0x86C | uint8[0x50] | bestTimes | Per-level best times (raw binary) |
| +0x8BC | uint8[0x50] | medals | Per-level medal status (0=none..3=gold) |
| +0x914 | int | playCount | Total launches from registry |
| +0x850 | bool | mirrorMode | Tournament mirror tracks |
| +0x84C | float | mouseSensitivity | 0.0 – 1.0 range |
| +0x238 | bool | rightButtonPause | Toggle pause on right-click |
Input / Controller Config
| Offset | Type | Name | Description |
|---|---|---|---|
| +0xB28 | DWORD | p2Controller1 | DirectInput device index |
| +0xB2C | DWORD | p2Controller2 | DirectInput device index |
| +0xB30 | DWORD | p2Controller3 | DirectInput device index |
| +0xB34 | DWORD | p2Controller4 | DirectInput device index |
Misc
| Offset | Type | Name | Description |
|---|---|---|---|
| +0x1B4 | char* | versionString | ProductVersion from Version API |
| +0x1CC | int | loadedCount | Objects loaded counter |
| +0x200 | bool | initialized | 1 after App_Initialize_Full finishes |
| +0x208 | char* | initStep | Debug string: "Initialize(1)".."(26)" |
| +0x240 | HCURSOR | cursor | "BLANKCURSOR" handle |
| +0x278 | Texture* | shadowTexture | Loaded shadow.png |
What You Can Do With the App Object
1. Instant Unlock Everything
// Set every unlock flag to 1
for (int i = 0x851; i <= 0x868; i++) {
*(bool*)(g_App + i) = true;
}
This unlocks every race and arena instantly. No registry editing, no file
patching. The game reads these flags every time it draws a menu.
2. Force Fullscreen / Windowed
// Toggle windowed mode
bool* windowed = (bool*)(g_App + 0x158);
*windowed = !*windowed;
// Then call App_SetFullScreen(0x0046C7C0) to apply
3. Change Mouse Sensitivity
float* sens = (float*)(g_App + 0x84C);
*sens = 2.0f; // Double default sensitivity
4. Skip Intro / Instant Menu
Hook App_Initialize_Full and replace the call at step 25:
Original:
call vtable[0xA0] ; Shows title screen
Replace with:
call App_ShowMainMenu ; 0x004280E0 — jumps straight to menu
5. Frame-By-Frame TAS Hook
Hook App_Run and replace vtable[0x20]() with your own function that
reads input from a file instead of DirectInput:
void MyUpdate() {
// Read next frame of inputs from TAS movie file
// Write them into InputDevice+0x50C..0x518 (DIK codes)
// Call original Scene_Update
OriginalSceneUpdate();
}
6. Force Quit / Safe Exit
// Set quit flag — game exits cleanly at next loop iteration
*(bool*)(g_App + 0x159) = true;
7. FPS Unlock / Frame Limiter Override
// Change target FPS (default is 30)
*(int*)(g_App + 0x05C) = 60;
*(int*)(g_App + 0x168) = 1000 / 60;
8. No-Clip / Disable Collision
The currentScene pointer at +0x178 leads to the Scene object, which
has a collision handler at vtable+0x29 (0x40C5D0). Replacing that vtable
slot with a no-op disables all collision events.
Scene* scene = *(Scene**)(g_App + 0x178);
void** scene_vt = *(void***)scene;
scene_vt[0x29] = (void*)0x44B840; // NoOp stub
9. Instant Level Load
App_StartRace (0x004287C0) takes a level path string and loads it
immediately. Call it from your hook to warp to any level:
typedef void (*App_StartRace_t)(App*, const char*);
App_StartRace_t StartRace = (App_StartRace_t)0x004287C0;
StartRace(g_App, "levels\\level10");
10. Music Speed / Tempo Hack
The BASS music handle is at App+0x534. Use BASS's BASS_ChannelSetAttribute
with BASS_ATTRIB_FREQ to change playback speed without touching game code.
Key Functions for Modding
| Address | Name | Why You Care |
|---|---|---|
| 0x004278E0 | WinMain | Earliest hook point |
| 0x00429530 | App_Initialize_Full | All systems ready, pre-menu |
| 0x0046BD80 | App_Run | Per-frame hook point |
| 0x0046C170 | App_FrameUpdate | Input+physics every frame |
| 0x0046C7C0 | App_SetFullScreen | Toggle display mode |
| 0x004280E0 | App_ShowMainMenu | Jump to menu |
| 0x004287C0 | App_StartRace | Load any level |
| 0x00425F90 | App_CompleteRace | Finish race instantly |
| 0x00428C50 | App_StartPracticeRace | Start practice mode |
| 0x004288B0 | App_StartTournamentRace | Start tournament |
| 0x0046CB70 | App_SetTitleString | Change window title |
| 0x0046C050 | App_CreateInputDevice | Add custom input device |
| 0x0046CB00 | App_CreateScoreDisplay | Inject HUD element |
| 0x0046BCA0 | App_WriteDisplaySettings | Save res to registry |
| 0x0046BD00 | App_ReadDisplaySettings | Load res from registry |
| 0x4284C0 | App_SaveAllConfig | Force save all settings |
Registry Persistence
The game saves all unlock flags and settings to the Windows registry on exit.
If you set flags in memory but want them to survive a restart, either:
- Call
App_SaveAllConfig(0x004284C0)withg_Appas the argument. - Let the game exit normally — it calls this automatically in
LoadOrSaveConfig.
Registry path (inferred): HKEY_CURRENT_USER\Software\Raptisoft\Hamsterball
C Header for Injection
#ifndef HB_APP_H
#define HB_APP_H
#include <stdint.h>
#include <windows.h>
typedef struct {
void** vtable; // +0x000
HINSTANCE hInstance; // +0x004
int cmdShow; // +0x008
uint8_t pad_00C[0x48]; // +0x00C..0x053
void* registryKey; // +0x054
uint8_t pad_058[0x04];
int targetFPS; // +0x05C
int frameTimeMs; // +0x5A (alias, verify)
int fpsDenominator; // +0x5B
void* renderTarget; // +0x5D
int frameCounter; // +0x65
uint8_t pad_069[0xEF];
uint8_t updateDisabled; // +0x156
uint8_t windowed; // +0x158
uint8_t minimizedFlag; // +0x15A
uint8_t quitFlag; // +0x159
int width; // +0x15C
int height; // +0x160
uint8_t pad_164[0x10];
void* graphics; // +0x174
void* audioSystem; // +0x17C
void* inputHandler; // +0x180
void* gameUpdateObj; // +0x184
uint8_t pad_188[0x2C];
char* versionString; // +0x1B4
int loadedCount; // +0x1CC
uint8_t initialized; // +0x200
char* initStep; // +0x208
uint8_t pad_20C[0x34];
HCURSOR cursor; // +0x240
uint8_t pad_244[0x34];
void* shadowTexture; // +0x278
uint8_t pad_27C[0x2B8];
HMUSIC musicHandle; // +0x534
HCHANNEL musicChannel1; // +0x538
HCHANNEL musicChannel2; // +0x53C
uint8_t pad_540[0x10];
void* gameMode1; // +0x550
void* gameMode2; // +0x554
void* gameMode3; // +0x558
void* gameMode4; // +0x55C
uint8_t pad_560[0x3B4];
void* currentScene; // +0x178 (RELOCATE — verify in Ghidra)
// ... continue from docs above
} App;
// Global singleton
static App** const g_ppApp = (App**)0x004FD680;
#define g_App (*g_ppApp)
#endif
Note: The header above uses approximate offsets. For production code,
verify every offset in Ghidra against the current binary build. The
App_Initialize_Fulldecompilation is the authoritative source.
Quick Reference: Offsets at a Glance
0x004FD680 g_App App* (global)
0x004CE400 App_vtable void**
0x004D0260 Scene_vtable void**
0x004D934C SceneObject_vtable void**
0x004CF300 Vec3_vtable void**
Files Referenced
| File | Description |
|---|---|
analysis/ghidra/structs/app_struct.h |
Ghidra C struct export |
analysis/ghidra/decompilations/app/decomp_app_initialize.c |
App_Initialize_Full decomp |
analysis/ghidra/decompilations/app/decomp_app_run.c |
App_Run decomp |
docs/FUNCTION_MAP.md |
Full function listing |
docs/SAVE_CONFIG_REGISTRY_SYSTEM.md |
Registry details |
docs/GAME_LOOP_WINDOW_MANAGEMENT.md |
Frame timing |
docs/STRUCTS_AND_TYPES.md |
Other structures |
Document version: 2026-06-04
Based on Hamsterball.exe analysis via Ghidra 12.0.4 + GhidraMCP
Phase 1 structs complete — App struct verified against decompilation
🔗 Related Documents
Timer/HUD System & Asset Manif
Timer/HUD System & Asset Manifest
Architecture Overview
The game's resource loader (misnamed TimerDisplay at 0x42A8C0) creates a LoadingScreenGadget (0x3628 bytes) which serves as both:
- Loading screen with progress indicator
- Asset manager for the entire game
The timer/HUD system draws on top of the 3D scene during gameplay:
- Time pool display (with timerblot.png background)
- Race name
- Safe circle indicator (safecircle.png)
- Medal/rank icons (bronze/silver/gold/goldenweasel)
Resource Loading Pipeline
LoadingScreenGadget
Created once at startup (0x3628 bytes). Loads ALL game assets via vtable dispatch:
| Vtable Offset | Function | Parameter |
|---|---|---|
| +0x48 | LoadTexture(destination, filename, mirror_flag) | .png/.bmp → Direct3D texture |
| +0x4C | LoadMesh(destination, filename) | .x mesh → D3DX mesh |
| +0x50 | LoadLevel(destination, filename) | Level → MeshWorld |
| +0x54 | AttachLevel(destination, source) | Attach secondary level to primary |
| +0x58 | LoadTextureSimple(destination, filename) | Texture without mirror variant |
| +0x5C | LoadFont(destination, fontname) | Font from fonts/ directory |
| +0x60 | LoadSound(destination, filename, channel_count) | Sound with channel pool count |
Loading Flow
App_ResourceLoader(app):
1. LoadingScreenGadget_Ctor(0x3628 bytes)
2. app->loading_screen = gadget // App+0x22C
PHASE 1: Fonts
- fonts\showcardgothic28 (title text, large)
- fonts\arialnarrow12bold (small text)
- fonts\showcardgothic14 (medium text)
- fonts\showcardgothic72 (huge numbers / timer display)
- fonts\showcardgothic16 (HUD text)
PHASE 2: UI Textures (titletext, hammy, blueblot, bluecircle)
PHASE 3: Race Textures (signs, goals, locktiles, arrows, checkers, bricks)
PHASE 4: Ball Meshes (Sphere, SphereBreak, Hamster anims, 8Ball, FunBall, Bell, Dizzy)
PHASE 5: Level Prefabs (MouseTrap, Secret, Secret-Unlock, Trapdoors, PopupSign)
PHASE 6: Object Meshes (tarbubble, fanblades, sawblade, dawgshoe, mace)
PHASE 7: HUD Textures (weaselbox, ballborder, timerblot, chrome, medals)
PHASE 8: Tournament Textures (tourney-* for each race)
PHASE 9: Result Textures (Burst, Lost, Winner, Winner2p, title1-4)
PHASE 10: Sound Effects (55 sounds with channel counts)
PHASE 11: Endgame Textures (settext, gotext)
12. Menu_MergeAllLists()
13. Scene_AddObject(app->scene, gadget)
Complete Asset Manifest
Fonts (5 fonts)
| App Offset | Font Name | Usage |
|---|---|---|
| +0x318 | showcardgothic28 | Title screens |
| +0x31C | showcardgothic14 | Medium dialogue |
| +0x320 | showcardgothic16 | HUD text |
| +0x324 | arialnarrow12bold | Small labels |
| +0x328 | showcardgothic72 | Timer display |
Ball Meshes (14 meshes)
| App Offset | Mesh | Description |
|---|---|---|
| +0x244 | Sphere | Standard ball |
| +0x248 | SphereBreak1 | Ball breaking animation 1 |
| +0x24C | SphereBreak2 | Ball breaking animation 2 |
| +0x250 | Hamster-Waiting | Hamster idle inside ball |
| +0x254 | Hamster-trot1 | Walk animation 1 |
| +0x258 | Hamster-trot2 | Walk animation 2 |
| +0x25C | Hamster-trot3 | Walk animation 3 |
| +0x260 | RBGlare | Light reflection on ball |
| +0x264 | Sphere+Tar | Ball with tar coating |
| +0x268 | 8Ball | 8-ball penalty ball |
| +0x26C | FunBall | Fun/bonus ball |
| +0x270 | Bell | Bell object |
| +0x274 | Dizzy | Dizzy animation |
Race Textures (16 categories × 2-3 variants each)
Each race has a checker + brick texture pair:
- pink, blue, green, yellow, grey-outline, red, orange, bright-green
- toob (tube race), sky, purple, brown, black
Goal textures: goal.png, goal-lit.png, goal-mirrored.png, goal-lit-mirrored.png
Round goal variants: goal-round*.png
Tournament Race Thumbnails (14 races)
| App Offset | Race Name |
|---|---|
| +0x3B4 | Beginner |
| +0x3B8 | Cascade |
| +0x3BC | Intermediate |
| +0x3C0 | Dizzy |
| +0x3C4 | Tower |
| +0x3C8 | Up |
| +0x3CC | Neon |
| +0x3D0 | Expert |
| +0x3D4 | Odd |
| +0x3D8 | Toob |
| +0x3DC | Wobbly |
| +0x3E0 | Glass |
| +0x3E4 | Sky |
| +0x3E8 | Master |
| +0x3EC | Impossible |
Sound Effects (55 sounds)
| App Offset | Sound | Channels |
|---|---|---|
| +0x43C | sounds\collide | 10 |
| +0x440 | sounds\roll | 10 |
| +0x444 | sounds\whistle | 1 |
| +0x448 | sounds\bumper | 10 |
| +0x44C | sounds\ballbreak | 5 |
| +0x450 | sounds\ballbreaksmall | 5 |
| +0x454 | sounds\thwomp | 2 |
| +0x458 | sounds\snap | 2 |
| +0x45C | sounds\popup | 2 |
| +0x460 | sounds\dropin | 2 |
| +0x464 | sounds\dropinshort | 2 |
| +0x468 | sounds\popout | 2 |
| +0x46C | sounds\pipebump1 | 10 |
| +0x470 | sounds\pipebump2 | 10 |
| +0x474 | sounds\pipebump3 | 10 |
| +0x478 | sounds\gearclank | 20 |
| +0x47C | sounds\bridgeslam | 2 |
| +0x480 | sounds\platformtick | 5 |
| +0x484 | sounds\gluestuck | 5 |
| +0x488 | sounds\bubble1 | 5 |
| +0x48C | sounds\bubble2 | 5 |
| +0x490 | sounds\wheelcreak | 2 |
| +0x494 | sounds\catapult | 2 |
| +0x498 | sounds\trapdoor | 2 |
| +0x49C | sounds\fwing | 2 |
| +0x4A0 | sounds\clink | 3 |
| +0x4A4 | sounds\whoosh | 3 |
| +0x4A8 | sounds\chomp | 1 |
| +0x4AC | sounds\fan-start | 10 |
| +0x4B0 | sounds\fan-blow | 10 |
| +0x4B4 | sounds\crack | 2 |
| +0x4B8 | sounds\crumble | 2 |
| +0x4BC | sounds\sawstartup | 2 |
| +0x4C0 | sounds\sawcut | 2 |
| +0x4C4 | sounds\minipop | 5 |
| +0x4C8 | sounds\bell | 3 |
| +0x4CC | sounds\zip | 2 |
| +0x4D0 | sounds\ting | 20 |
| +0x4D4 | sounds\shrink | 3 |
| +0x4D8 | sounds\grow | 3 |
| +0x4DC | sounds\tweet | 3 |
| +0x4E0 | sounds\creakyplatform | 20 |
| +0x4E4 | sounds\wubba | 5 |
| +0x4E8 | sounds\saw | 2 |
| +0x4EC | sounds\sawspeedy | 2 |
| +0x4F0 | sounds\dawgstep1 | 10 |
| +0x4F4 | sounds\dawgstep2 | 10 |
| +0x4F8 | sounds\dawgsmash | 10 |
| +0x4FC | sounds\sizzle | 2 |
| +0x500 | sounds\explode | 3 |
| +0x504 | sounds\vac-o-sux | 3 |
| +0x508 | sounds\speedcylinder | 2 |
| +0x50C | sounds\bonuspop | 5 |
| +0x510 | sounds\buzzbonus | 1 |
| +0x514 | sounds\breakbridge | 1 |
| +0x518 | sounds\unlock | 1 |
| +0x51C | sounds\NeonRide | 1 |
| +0x520 | sounds\NeonFlicker | 50 |
| +0x524 | sounds\ZoopDown | 2 |
| +0x528 | sounds\LightsOff | 2 |
| +0x52C | sounds\GlassBonus | 2 |
Timer System
ArenaBoard Timer (Ball+0x264)
Each ball has a ArenaBoard timer component (16 bytes):
- Used in rumble/arena mode for round timing
ToggleTimer_Initresets the timer- Timer counts down during gameplay
- When timer reaches zero: round ends
Tournament Time Pool
PlayerProfile+0x5E4= total accumulated time (float, for ranking)App+0x5E8= active time pool (float)App+0x5F4= (unknown timing float)- Time pool decreases during race; when it hits zero = "LOST TOURNAMENT"
- Warm-up race (race 1) does NOT affect time pool
Timer Display
- Font: showcardgothic72 (large digits)
- Background: timerblot.png
- Positioned in HUD overlay layer (alpha pass)
- Displayed as minutes:seconds remaining
HUD Elements
| Element | Texture | Location |
|---|---|---|
| Timer background | timerblot.png | Top-center |
| Safe circle | safecircle.png | Progress indicator |
| Ball border | ballborder.png | Ball highlight ring |
| Ball burner | ballburner.png | Speed effect overlay |
| Sweat drop | sweat.png | Ball strain indicator |
| Star burst | star.png | Achievement effect |
| Dust cloud | dust.png | Impact effect |
| Chrome ball | chrome.png / chromeshadow.png | Premium ball skin |
| Medal (bronze) | bronze-small.png, bronze-icon.png | Tournament score |
| Medal (silver) | silver-small.png, silver-icon.png | |
| Medal (gold) | gold-small.png, gold-icon.png | |
| Golden weasel | goldenweasel.png, goldenweasel-icon.png | Best medal |
| Lock icon | lock.png | Locked content |
| Rank badge | textures\ranks%d.jpg | Post-race rank display |
Key Address Map
| Address | Function | Description |
|---|---|---|
| 0x42A8C0 | App_ResourceLoader | Load ALL game assets |
| 0x458E60 | ToggleTimer_Init | Reset round timer |
Reimplementation Notes (SDL2)
Asset Loading
- Use a manifest file (JSON/YAML) instead of hardcoded loading
- SDL2:
IMG_Load()for textures,TTF_OpenFont()for fonts - For meshes: custom mesh format or GLTF2
- For sounds:
Mix_LoadWAV()/Mix_Music
Timer System
class TimerSystem {
float time_pool; // Tournament time remaining
float race_elapsed; // Current race time
bool is_warmup; // Race 1 doesn't affect pool
void Update(float dt) {
race_elapsed += dt;
if (!is_warmup) time_pool -= dt;
if (time_pool <= 0) OnTournamentLost();
}
void Render() {
// Render timer display with showcardgothic72 font
// Background with timerblot.png
// Format as MM:SS
}
};
🔗 Related Documents
Tower & Dizzy Object System
Hamsterball Tower & Dizzy Object System — Complete Reverse Engineering
Factory Functions
CreateDizzyObjects @ 0x0040A5F0
Dizzy Race level-specific factory. Dispatches via __strnicmp on object names from MESHWORLD data.
| Object | Match String | VA | Alloc | Constructor | Board Mesh | AthenaList | Difficulty |
|---|---|---|---|---|---|---|---|
| TIPPER | "TIPPER", 6 | 0x4CF69C | 0x1104 | Tipper_ctor | +0x4394 | +0x2578 | YES |
| WATERWHEEL | "WATERWHEEL", 10 | 0x4CF690 | NONE | NONE (Board fields) | +0x4BA8/+0x4BAC | NO | NO |
| SWIRL | "SWIRL", 5 | 0x4CF688 | NONE | NONE (Board fields) | +0x4BC4/+0x4BC8 | NO | NO |
| GLUEBIE | "GLUEBIE", 7 | 0x4CF680 | 0x110C | Gluebie_ctor | +0x4374 | +0x2578/+0x6080 | YES |
CreateTowerObjects @ 0x0040D7C0
Tower Race level-specific factory. Handles 7 object types.
| Object | Match String | VA | Alloc | Constructor | Board Mesh | AthenaList | Difficulty |
|---|---|---|---|---|---|---|---|
| CATAPULT | "CATAPULT", 8 | 0x4CF99C | 0x1108 | Catapult_ctor @ 0x437E10 | +0x436C | +0x2578/+0x584C | NO |
| MACE | "MACE", 4 | 0x4CF994 | 0x110C | 0x438750 | +0x4378 | +0x2578 | NO |
| DRAWBRIDGE | "DRAWBRIDGE", 10 | 0x4CF988 | 0x113C | Glass_Level_ctor @ 0x4384A0 | +0x4370 | +0x2578 (×2) | NO |
| WINDMILL | "WINDMILL", 8 | 0x4CF97C | 0x10D0 | CollisionLevel_ctorWithLevel @ 0x465080 | +0x437C | NO | NO |
| TRAPDOOR | "TRAPDOOR", 8 | 0x4CF970 | 0x10F8 | 0x438290 | N/A | +0x2578 (×6) | NO |
| CHOMPER | "CHOMPER", 7 | 0x4CF968 | NONE | NONE (Board fields) | +0x4390 | NO | NO |
| TURRET | "TURRET", 6 | 0x4CF960 | 0x10D0 | Stands_ctor @ 0x462850 | +0x43B4 | NO (vtable) | NO |
Mesh Path Strings
| VA | String | Board Offset | Used By |
|---|---|---|---|
| 0x4D0794 | Levels\Level3-WaterWheel | +0x4BA8 | Dizzy WATERWHEEL |
| 0x4D099C | Levels\Level4-Drawbridge | +0x4370 | Tower DRAWBRIDGE |
| 0x4D0974 | Levels\Level4-Mace | +0x4378 | Tower MACE |
| 0x4D095C | Levels\Level4-Windmill | +0x437C | Tower WINDMILL |
| 0x4D094C | Meshes\Chomper | +0x4390 | Tower CHOMPER |
| 0x4D0932 | Levels\Level4-Turret | +0x43B4 | Tower TURRET |
MESHWORLD Placement
| File | Objects Found |
|---|---|
| Level3-WaterWheel.MESHWORLD | WATERWHEEL |
| Level4.MESHWORLD | CHOMPER, DRAWBRIDGE, WINDMILL, MACE, TURRET |
| Level4-Trapdoor1.MESHWORLD | TRAPDOOR |
| Level4-Trapdoor2.MESHWORLD | TRAPDOOR |
| Arena-Tower.MESHWORLD | MACE, TURRET |
| Level9.MESHWORLD | TRAPDOOR |
Spawnability Verdict
| Object | Spawn? | Reason |
|---|---|---|
| WaterWheel | NO | No alloc, no ctor, no vtable — just Board field write. Static mesh only. |
| Chomper | NO | No alloc, no ctor — MeshNode visual only. No collision or game logic. |
| Drawbridge | YES | Alloc 0x113C, Glass_Level_ctor, appended to +0x2578. Needs Board+0x4370 mesh. |
| Mace | YES | Alloc 0x110C, ctor 0x438750, appended to +0x2578. Needs Board+0x4378 mesh. |
| Windmill | PARTIAL | CollisionLevel only (collision, no game object/render). Not in +0x2578. |
| Trapdoor | YES | Alloc 0x10F8, ctor 0x438290, appended to +0x2578. No mesh dependency. |
| Turret | PARTIAL | Stands_ctor but no AthenaList_Append. Uses vtable dispatch for rendering. |
Key Functions
- MeshWorld_ctor: 0x00461510 — alloc(0x10D0, App+0x174, "path")
- CollisionLevel_ctorWithLevel: 0x00465080
- Stands_ctor: 0x462850
- AthenaList_Append: 0x00453810
- operator_new: 0x4BA57B (jmp to malloc)
- __strnicmp: 0x4C7677
Global Spawn Pattern (for functional objects)
For objects that CAN be spawned (Drawbridge, Mace, Trapdoor):
- Get Board pointer: ball+0x14 → Board
- Get App pointer: Board+0x878 → App
- Load required mesh: MeshWorld_ctor(0x10D0, App+0x174, meshPath) → store at Board+offset
- Allocate object: operator_new(size)
- Call constructor: Ctor(alloc, Board, mesh) — thiscall (ecx=alloc, push Board+mesh)
- Set position: obj+0x10D4/+0x10D8/+0x10DC = param_block pos
- Append to list: AthenaList_Append(Board+0x2578, obj)
🔗 Related Documents
UI & Menu System
UI & Menu System
Overview
Hamsterball's menu system uses a class hierarchy:
- SimpleMenu (base, 0x870 bytes) — title, UIList, button handling
- MainMenu — vtable 0x4D3F30, title "Main Menu"
- DifficultyMenu — vtable 0x4D40A8, title "CHOOSE A DIFFICULTY!"
- PracticeMenu, TimeTrialMenu, PartyMenu — level selection
- TourneyMenu — tournament progression
- OptionsMenu — settings with control rendering
- PauseMenu — in-race pause
- QuitRaceMenu — quit confirmation
- HighScoreMenu — score display
- ConfirmMenu — generic confirmation dialog
Menu Class Hierarchy
Gadget (0x870 bytes)
└─ SimpleMenu (derived from Gadget)
├─ MainMenu (vtable 0x4D3F30)
├─ DifficultyMenu (vtable 0x4D40A8)
├─ PracticeMenu
├─ TimeTrialMenu
├─ PartyMenu
├─ TourneyMenu
├─ OptionsMenu
├─ PauseMenu
└─ ConfirmMenu
MainMenu Items (0x42DE50)
Menu items are added via UIList_AddItem(menu, "Display Text", "ID_CODE", ...):
| Display Text | ID Code | Condition | Scale Factor |
|---|---|---|---|
| LET'S PLAY! | PLAY | Always | (0.75, 1.0, 0.75, 1.0) |
| (spacer 10) | - | Always | - |
| HIGH SCORES | HS | Always | (1.0, 1.0, 1.0, 1.0) |
| OPTIONS | OP | Always | (1.0, 1.0, 1.0, 1.0) |
| CREDITS | CR | Always | (1.0, 1.0, 1.0, 1.0) |
| REGISTER GAME | RG | If App+0x200==0 (not registered) | (1.0, 1.0, 0, 1.0) |
| (spacer 10) | - | Always | - |
| (mini-game) | MG | If App+0x918 && App+0xB24 | (width, height) |
| (spacer 10) | - | If mini-game shown | - |
| EXIT TO DESKTOP | EXIT | Always | (1.0, 0.75, 0.75, 1.0) |
DifficultyMenu Items (0x42E220)
| Display Text | ID Code | Scale Factor |
|---|---|---|
| PIPSQUEAK | EASY | (0.7, 1.0, 0.7, 1.0) |
| NORMAL | NORMAL | (1.0, 1.0, 0.7, 1.0) |
| FRENZIED! | HARD | (1.0, 0.5, 0.5, 1.0) |
| (spacer 10) | - | - |
| PREVIOUS | BACK | (0.75, 0.75, 1.0, 1.0) |
Difficulty Mapping
- EASY (0): MouseSensitivity default, slower AI
- NORMAL (1): Default game speed
- HARD (2): Frenzied mode — faster objects, harder tracks
SimpleMenu Layout (0x870 bytes, extends Gadget)
Key offsets specific to SimpleMenu:
| Offset | Type | Description |
|---|---|---|
| +0x44C | AthenaList | Font string list |
| +0x868 | char* | Menu title string |
| +0x86C | AthenaList | Item list (alternate) |
| +0x88C | AthenaList | UIListItem list |
| +0xCAC | int | Min width |
| +0xCB0 | int | Max width |
| +0xCB4 | int | Total height |
| +0xCBC | byte | Needs recalculation flag |
UIList_AddItem (0x449820)
Creates UIListItem (0x444 bytes) per menu entry:
- Allocate UIListItem (0x444 bytes)
- Copy display text string → item+0x00 (strdup)
- Copy ID code string → item+0x04 (strdup)
- Store matrix transform → item+0x0C..0x18
- Create AthenaString (0x1C bytes) for rendering → item+0x1C
- Store font reference → item+0x20
- Append to font list (SimpleMenu+0x44C)
- Append to item list (SimpleMenu+0x88C)
- Calculate text width via Font_MeasureText
- Update menu total height (SimpleMenu+0xCB4)
- Update menu max width (SimpleMenu+0xCB0)
UIListItem Structure (0x444 bytes)
| Offset | Type | Description |
|---|---|---|
| +0x00 | char* | Display text (e.g., "LET'S PLAY!") |
| +0x04 | char* | ID code (e.g., "PLAY") |
| +0x0C | float[4] | Matrix scale row (X1, X2, Y1, Y2) |
| +0x1C | AthenaString* | Rendered text object |
| +0x20 | int | Font data reference |
| +0x24 | int | Item height |
| +0x110 | byte | Hidden flag |
| +0x441 | byte | Disabled flag |
Menu Command Dispatch
When a menu item is selected, the ID code string is dispatched through
App_ShowMainMenu (0x4280E0) which uses a strcmp chain:
"PLAY" → Show difficulty selection
"HS" → Show high scores
"OP" → Show options menu
"CR" → Show credits
"RG" → Show registration
"MG" → Launch mini-game
"EXIT" → Quit to desktop
"EASY" → Start practice race (difficulty 0)
"NORMAL" → Start practice race (difficulty 1)
"HARD" → Start practice race (difficulty 2)
"BACK" → Return to previous menu
Pause System
PauseMenu_Ctor (0x42E4B0)
In-race pause menu with:
- RESUME → continue racing
- QUIT → quit race menu
QuitRaceMenu (0x42E6F0)
Confirmation dialog:
- YES, QUIT → end race
- NO, CONTINUE → resume racing
TourneyMenu System
TourneyMenu_Advance (0x42E210)
Handles tournament progression after race completion.
TourneyMenu_GetRaceName (0x4264A0)
Returns race name string for current tournament position.
TourneyMenu_WriteSave (0x4264B0)
Saves tournament progress to registry.
TourneyMenu_LoadSaveAndShow (0x4265A0)
Loads saved tournament state and displays current race.
GraphicsOptionsMenu (0x42E840)
OptionsMenu_RenderControls renders control mapping:
- Lists all DirectInput devices
- Shows key bindings (up/down/left/right/action)
- Supports keyboard, mouse, joystick remapping
LoadingScreenGadget (0x3628 bytes)
Created by TimerDisplay (0x4298C0 — actually App_ResourceLoader).
Manages the loading screen during initial asset loading.
See ASSET_MANIFEST.md for complete list of loaded resources.
Menu Input Handling
Scene_HandleInput (0x4692F0):
- Checks each gadget in scene's gadget list (Scene+0x858)
- If gadget is input active (+0x16 != 0):
- Dispatches input through gadget vtable[1]
- If gadget captures input (+0x14 != 0): marks as active
- Player 1 uses App+0x1E4 input channel
- Player 2 uses App+0x1E8 input channel
- Plays sound effect via App+0x1DC sound channel
Key Menu Functions
| Address | Function | Description |
|---|---|---|
| 0x42DE50 | MainMenu_ctor | "Main Menu" — PLAY/HS/OP/CR/RG/MG/EXIT |
| 0x42E220 | DifficultyMenu_ctor | "CHOOSE A DIFFICULTY!" — EASY/NORMAL/HARD/BACK |
| 0x42EA30 | PracticeMenu_ctor | Level selection for practice mode |
| 0x42F810 | TimeTrialMenu_ctor | Level selection for time trials |
| 0x42FC10 | PartyMenu_ctor | Level selection for party/multiplayer |
| 0x42E4B0 | PauseMenu_Ctor | In-race pause: RESUME/QUIT |
| 0x42E6F0 | QuitRaceMenu | Quit confirmation: YES/NO |
| 0x42B190 | ConfirmMenu_ctor | Generic yes/no dialog (6 xrefs) |
| 0x42B890 | HighScoreMenu_Render | High score display |
| 0x449820 | UIList_AddItem | Add menu item with display text + ID code |
| 0x4497F0 | UIList_AddSpacer | Add vertical spacing |
| 0x4280E0 | App_ShowMainMenu | Menu command dispatcher |
| 0x4284C0 | App_SaveAllConfig | Save settings to registry on menu exit |
| 0x4279F0 | LoadOrSaveConfig | Cleanup + save on game exit |
🔗 Related Documents
UI Text Elements
Hamsterball UI Text Elements: Drawing On-Screen Text
Scope: Original Hamsterball.exe (PE32, i386, Athena engine, VS2003).
Method: Direct Ghidra decompilation and disassembly verification.
Last Updated: 2026-06-20 (revision 2 — vtable params, Color struct passing, troubleshooting)
1. Overview
This document explains how to draw text on screen in Hamsterball using the
engine's built-in bitmap-font rendering pipeline. It covers the correct this
(Font*) pointer chain, function signatures, color/transform struct layout, and
practical DLL-mod examples.
The old version of this doc incorrectly described the this pointer as
"GraphicsDevice/App" and gave wrong parameter descriptions. All findings below
are verified against raw Ghidra decompilation and disassembly, cross-referenced
with actual call sites in the game code.
Revision 2 adds: critical finding that params 6 and 11 are overwritten
internally by UI_DrawTextCentered/UI_DrawTextShadow_Wrapper, troubleshooting
for Color struct passing, and a concrete failing-call analysis.
2. The Font Object
All text drawing functions are __thiscall with this = a Font object
pointer, NOT the App, GraphicsDevice, or Scene.
2.1 Font* pointer chain from App
The global App pointer is at 0x005341E0:
void* g_App = *(void**)0x005341E0;
the app object stores pointers to loaded Font objects at these offsets
(verified via decomp_resource_manifest.c — the master asset loader at
0x0042A8C0):
| App Offset | Font Path | Usage |
|---|---|---|
+0x318 |
fonts\showcardgothic28 |
Main title / UI text |
+0x31C |
fonts\showcardgothic14 |
Small label text |
+0x324 |
fonts\arialnarrow12bold |
UI detail text |
+0x328 |
fonts\showcardgothic72 |
Race timer digits (large) |
+0x320 |
fonts\showcardgothic16 |
Info text |
2.2 How to get the Font* in a DLL mod
// Global App pointer (Athena engine)
void* g_App = *(void**)0x005341E0;
// Get font pointers
void* font_title = *(void**)((char*)g_App + 0x318); // showcardgothic28
void* font_small = *(void**)((char*)g_App + 0x31C); // showcardgothic14
void* font_detail = *(void**)((char*)g_App + 0x324); // arialnarrow12bold
void* font_timer = *(void**)((char*)g_App + 0x328); // showcardgothic72
void* font_info = *(void**)((char*)g_App + 0x320); // showcardgothic16
2.3 Font struct layout (from LoadFont at 0x00457130)
| Offset | Type | Field |
|---|---|---|
+0x00 |
void* |
vtable |
+0x04 |
void* |
Graphics device ptr |
+0x08 |
AthenaList | Glyph texture list |
+0x420 |
int |
Space width |
+0x424 |
int |
Max line height |
+0x428 |
float |
Scale (default 1.0 = 0x3f800000) |
+0x42C |
char[0x500] |
Per-glyph table (0x14 bytes × 128 entries) |
Each glyph entry at font + char × 0x14 + 0x42C:
| Offset | Type | Field |
|---|---|---|
+0x00 |
char |
Valid flag |
+0x04 |
int |
advance_width |
+0x08 |
int |
offset_x |
+0x0C |
int |
offset_y |
+0x10 |
int |
width |
+0x14 |
void* |
sprite ptr |
Note: The glyph entry offsets are approximate based on
LoadFontand
Font_DrawGlyphdecompilation. The key fields used by the renderer are:
valid flag (+0x00), advance width (+0x04→+0x430via index math),
and sprite pointer (+0x43C).
3. Color / Transform Struct
The text-drawing functions use a 5-DWORD struct for color/transform data.
This is NOT a simple RGBA int — it's a struct with a vtable pointer followed
by 4 floats (R, G, B, A).
3.1 Static identity transform
A pre-built static struct exists at 0x004CF300:
| Offset | Value | Meaning |
|---|---|---|
+0x00 |
0x00401070 |
vtable (Vec3_dtor) |
+0x04 |
255.0f |
R (white) |
+0x08 |
(garbage) | G |
+0x0C |
1.45f |
B / scale |
In practice, most callers use Matrix_Scale4x4 to build a fresh 4×4 matrix on
the stack, then pass its address. The first DWORD of the matrix is treated as a
vtable pointer by the rendering code.
3.2 How the game builds color structs
From TourneyContinueDialog_Render (0x00445F50):
// The game creates two 20-byte structs on the stack
// (one for text color, one for shadow color)
// via Matrix_Scale4x4, then passes them to UI_DrawTextShadow.
float text_color[5]; // [vtable, R, G, B, A]
float shadow_color[5]; // [vtable, R, G, B, A]
// Matrix_Scale4x4 fills the struct with a scale matrix
Matrix_Scale4x4(text_color, 0, 0, 0, 1.0f); // identity
Matrix_Scale4x4(shadow_color, 1.0f, 1.0f, 1.0f, 1.0f); // white shadow
Practical shortcut: Use 0x004CF300 as the vtable pointer value and fill
the rest with floats. Or better yet, use the wrapper function (§4.3) which
auto-fills the vtable.
4. Text Drawing Functions
4.1 UI_DrawTextShadow — Full control (15 stack params + this)
Address: 0x004012C0
Calling convention: __thiscall (ECX = Font*)
RET: 0x3C (15 × 4 = 60 bytes cleaned)
void __thiscall UI_DrawTextShadow(
Font* this, // ECX
char* text, // [ESP+0x00] param_1
int x, // [ESP+0x04] param_2
int y, // [ESP+0x08] param_3
int shadow_dx, // [ESP+0x0C] param_4
int shadow_dy, // [ESP+0x10] param_5
void* text_xform_vtbl,// [ESP+0x14] param_6 — vtable ptr for text color
float text_r, // [ESP+0x18] param_7
float text_g, // [ESP+0x1C] param_8
float text_b, // [ESP+0x20] param_9
float text_a, // [ESP+0x24] param_10
void* shdw_xform_vtbl,// [ESP+0x28] param_11 — vtable ptr for shadow color
float shadow_r, // [ESP+0x2C] param_12
float shadow_g, // [ESP+0x30] param_13
float shadow_b, // [ESP+0x34] param_14
float shadow_a // [ESP+0x38] param_15
);
This is the lowest-level text function. It draws text with a drop-shadow.
The color params are part of 5-DWORD transform structs (vtable + 4 floats).
This is the most complex function and hardest to call directly.
Prefer the wrapper (§4.3) or Font_DrawCentered (§4.4) instead.
4.2 UI_DrawTextCentered — Centered with shadow (15 params + this)
Address: 0x00409C60
Calling convention: __thiscall (ECX = Font*)
RET: 0x3C (15 × 4 = 60 bytes cleaned)
Same signature as UI_DrawTextShadow. Internally:
- Builds two 5-DWORD color structs on the stack, using
0x4CF300as the
vtable pointer (params 6 and 11 from the caller are ignored/overwritten) - Calls
Font_MeasureText(this, text)to get text width - Subtracts width/2 from x to center
- Calls
UI_DrawTextShadowwith the two internally-built structs
The this pointer MUST be a Font*, obtained via *(App + 0x318) etc.
CRITICAL (verified from disassembly): Params 6 and 11 are overwritten
with the hardcoded absolute address0x4CF300inside the function body:00409c7f: MOV dword ptr [EAX],0x4cf300 ; overwrites param_6 slot 00409cae: MOV dword ptr [EAX],0x4cf300 ; overwrites param_11 slotThis means you do NOT need to pass a valid vtable pointer for params 6
and 11 — they are replaced internally. Pass 0, NULL, or any value; it
will be overwritten. The actual color data lives in params 7–10 (text
RGBA) and 12–15 (shadow RGBA) as individual floats.
4.3 UI_DrawTextShadow_Wrapper — Easiest high-level (15 params + this)
Address: 0x00409B90
Calling convention: __thiscall (ECX = Font*)
RET: 0x3C (15 × 4 = 60 bytes cleaned)
void __thiscall UI_DrawTextShadow_Wrapper(
Font* this, // ECX
char* text, // param_1 — text to draw
int x, // param_2 — screen X
int y, // param_3 — screen Y
int shadow_dx, // param_4 — shadow pixel offset X (typically 2-5)
int shadow_dy, // param_5 — shadow pixel offset Y (typically 2-5)
void* unused_6, // param_6 — IGNORED (replaced with &0x4CF300 internally)
float text_r, // param_7 — text red (0.0-1.0)
float text_g, // param_8 — text green (0.0-1.0)
float text_b, // param_9 — text blue (0.0-1.0)
float text_a, // param_10 — text alpha (0.0-1.0)
void* unused_11, // param_11 — IGNORED (replaced with &0x4CF300 internally)
float shadow_r, // param_12 — shadow red
float shadow_g, // param_13 — shadow green
float shadow_b, // param_14 — shadow blue
float shadow_a // param_15 — shadow alpha
);
This is the RECOMMENDED function for DLL mods. It auto-fills the vtable
pointers (0x4CF300) for both color structs, so you only pass raw RGBA floats.
Pass 0 (NULL) for params 6 and 11 — they're overwritten internally.
4.4 Font_DrawCentered — No shadow, auto-centered (8 params + this)
Address: 0x0042C870
Calling convention: __thiscall (ECX = Font*)
RET: 0x20 (8 × 4 = 32 bytes cleaned)
void __thiscall Font_DrawCentered(
Font* this, // ECX
char* text, // param_1
int x, // param_2 — center X
int y, // param_3 — Y
void* unused_4, // param_4 — IGNORED (overwritten with &0x4CF300)
float r, // param_5 — red (0.0-1.0)
float g, // param_6 — green (0.0-1.0)
float b, // param_7 — blue (0.0-1.0)
float a // param_8 — alpha (0.0-1.0)
);
This is the simplest text function — no shadow, auto-centered, auto-fills
the transform vtable. Internally calls Font_MeasureText then Font_DrawGlyph.
Note on color: When font scale (Font+0x428) == 1.0f (the default), the
color params are IGNORED — each glyph sprite is drawn via Sprite_DrawRect
which uses Color_RandomRGBA() internally. To get colored text, you must
either set Font+0x428 to something other than 1.0 (which triggers the
Scene_CreateObject4f path that uses the color params), or accept the default
white text from the sprite textures.
4.5 Font_DrawGlyph — Raw glyph rendering (8 params + this)
Address: 0x00457440
Calling convention: __thiscall (ECX = Font*)
RET: 0x20 (8 × 4 = 32 bytes cleaned)
void __thiscall Font_DrawGlyph(
Font* this, // ECX
char* text, // param_1 — text (iterates each char)
int x, // param_2 — start X (left-aligned)
int y, // param_3 — start Y
void* xform_vtbl, // param_4 — vtable ptr (use 0x4CF300)
float r, // param_5
float g, // param_6
float b, // param_7
float a // param_8
);
This is the lowest-level text function. Iterates each character, looks up its
glyph at Font + char × 0x14 + 0x42C, and draws it via Sprite_DrawRect
(scale 1.0) or Scene_CreateObject4f (scaled). Same color caveat as
Font_DrawCentered — colors only work when scale ≠ 1.0.
4.6 Font_MeasureText — Measure string width
Address: 0x00456E20
Calling convention: __thiscall (ECX = Font*)
RET: 0x04 (1 × 4 = 4 bytes cleaned)
int __thiscall Font_MeasureText(Font* this, char* text);
Returns total advance width in pixels for the string. Uses Font+0x428 (scale)
and each glyph's advance width at Font + char × 0x14 + 0x430.
4.7 Font_DrawGlyph3D — World-space text (18 params + this)
Address: 0x00457690
Calling convention: __thiscall (ECX = Font*)
RET: 0x48 (18 × 4 = 72 bytes cleaned)
Draws text in 3D world space with arbitrary orientation vectors. Too complex
for simple HUD use. Not recommended for DLL mods.
5. Why 0x409C60 May Not Work
The function at 0x409C60 (UI_DrawTextCentered) is correct and works,
but there are several reasons it may fail in practice:
5.1 Wrong this pointer
The #1 cause of failure. The old version of this doc said this was
"GraphicsDevice/App" — this is wrong. The this pointer MUST be a Font*
obtained from App+0x318 (or another font offset). Passing the App, Scene,
or GraphicsDevice pointer will crash inside Font_DrawGlyph when it tries to
access this+0x42C (the glyph table).
Correct:
void* app = *(void**)0x005341E0;
void* font = *(void**)((char*)app + 0x318);
UI_DrawTextCentered(font, "Hello", 400, 300, ...);
Wrong (will crash):
void* app = *(void**)0x005341E0;
UI_DrawTextCentered(app, "Hello", 400, 300, ...); // CRASH!
5.2 Too many parameters
UI_DrawTextCentered takes 15 stack params + ECX (this). If you're calling it
from C/C++ with __thiscall, you must push exactly 15 DWORDs. Many callers
get the count wrong. Use Font_DrawCentered (8 params) or
UI_DrawTextShadow_Wrapper (15 params but auto-fills vtable) instead.
5.3 Color struct vtable not set — DEBUNKED
Previous doc said: "Params 6 and 11 must be a valid vtable pointer
(0x4CF300) or the function will crash."WRONG. Verified from disassembly:
UI_DrawTextCentered(0x409C60)
overwrites params 6 and 11 with the hardcoded immediate0x4CF300:00409c7f: MOV dword ptr [EAX],0x4cf300 ; param_6 overwritten 00409cae: MOV dword ptr [EAX],0x4cf300 ; param_11 overwrittenThe same is true for
UI_DrawTextShadow_Wrapper(0x409B90).
Params 6 and 11 are always replaced internally — you can pass 0, NULL,
or garbage. The actual color data is the 4 floats after each vtable slot
(params 7–10 and 12–15).
5.4 Font not loaded yet
The font pointers at App+0x318 etc. are only valid after the resource
loader (0x0042A8C0) has completed. If you call during early initialization,
the pointer will be NULL. Hook after the loading screen completes.
5.5 Color struct not expanded to individual floats
If your mod API wrapper (CallMethod or similar) takes a Color struct
(e.g. struct Color { float r, g, b, a; }) as a single parameter, it may
pass it as a pointer (1 DWORD) instead of pushing 4 individual floats
onto the stack. This results in only 9 DWORDs on the stack instead of 15,
causing stack corruption and a crash when the function executes RET 0x3C
(it tries to clean 60 bytes but only 36 were pushed).
Fix: Pass each color component as a separate float argument:
// WRONG — Color struct may be passed as pointer (1 DWORD):
CallMethod(0x409C60, font, "text", x, y, 5, 5,
0, Color(0.5f, 0.5f, 0.5f, 0.8f),
0, Color(0.0f, 0.0f, 0.0f, 1.0f));
// CORRECT — 15 individual DWORDs on the stack:
CallMethod(0x409C60, font, "text", x, y, 5, 5,
0, // param_6 (ignored, overwritten)
0.5f, 0.5f, 0.5f, 0.8f, // params 7-10: text RGBA
0, // param_11 (ignored, overwritten)
0.0f, 0.0f, 0.0f, 1.0f); // params 12-15: shadow RGBA
5.6 Y coordinate at screen edge
Setting y=0 places text at the very top of the screen. Depending on the
font's glyph baseline offset and the viewport, the text may be partially or
fully clipped. Use y=20 or higher to ensure visibility.
5.7 Failing call analysis (real example)
This call was reported as not working:
DWORD vtable = baseAddr + 0xCF300;
void* font = *(void**)((char*) api->GetApp() + 0x318);
CallMethod(0x409C60, font, (char*)"69420", 437, 0, 5, 5,
vtable, Color(.5f, .5f, .5f, .8f),
vtable, Color(0.0f, 0.0f, 0.0f, 1.0f));
Issues identified:
-
vtableparam is unnecessary. Params 6 and 11 are overwritten with
0x4CF300inside the function. ThebaseAddr + 0xCF300calculation is
wasted effort and irrelevant. -
Color()struct passing. IfCallMethodpassesColor()as a struct
by pointer (1 DWORD) instead of expanding to 4 floats (4 DWORDs), the
stack is misaligned. This is the most likely cause of failure.
Total params would be 9 instead of 15. -
y=0may cause text to be clipped at the top of the screen. -
No null check on font. If
App+0x318is NULL (font not loaded yet),
the call will crash.
Recommended fix — use Font_DrawCentered (8 params, simplest):
void* font = *(void**)((char*)api->GetApp() + 0x318);
if (!font) return;
CallMethod(0x42C870, font, (char*)"69420", 437, 20,
0, // ignored (overwritten with 0x4CF300)
0.5f, 0.5f, 0.5f, 0.8f); // RGBA (note: only works if font scale ≠ 1.0)
Or use UI_DrawTextShadow_Wrapper (15 params, with shadow):
void* font = *(void**)((char*)api->GetApp() + 0x318);
if (!font) return;
CallMethod(0x409B90, font, (char*)"69420", 437, 20, 3, 3,
0, // ignored
0.5f, 0.5f, 0.5f, 0.8f, // text RGBA
0, // ignored
0.0f, 0.0f, 0.0f, 1.0f); // shadow RGBA
6. Recommended Approach for DLL Mods
6.1 Best: Use UI_DrawTextShadow_Wrapper (0x409B90)
This is the easiest function because it auto-fills the color struct vtable:
typedef void (__thiscall *DrawTextShadowWrapper_t)(
void* font, char* text, int x, int y,
int sx, int sy, void* unused1,
float tr, float tg, float tb, float ta,
void* unused2, float sr, float sg, float sb, float sa);
// At address 0x409B90 in the original EXE
DrawTextShadowWrapper_t DrawTextShadowWrapper =
(DrawTextShadowWrapper_t)0x00409B90;
void DrawHUDText(const char* text, int x, int y) {
void* app = *(void**)0x005341E0;
void* font = *(void**)((char*)app + 0x318); // showcardgothic28
if (!font) return; // font not loaded yet
DrawTextShadowWrapper(
font, // ECX = Font*
(char*)text, // text
x, y, // position
3, 3, // shadow offset (3px right, 3px down)
(void*)0, // unused (auto-filled)
1.0f, 1.0f, 1.0f, 1.0f, // text color: white, opaque
(void*)0, // unused (auto-filled)
0.0f, 0.0f, 0.0f, 1.0f // shadow color: black, opaque
);
}
6.2 Alternative: Use Font_DrawCentered (0x0042C870)
Simpler signature (8 params), no shadow, auto-centered:
typedef void (__thiscall *FontDrawCentered_t)(
void* font, char* text, int x, int y,
void* unused, float r, float g, float b, float a);
FontDrawCentered_t FontDrawCentered =
(FontDrawCentered_t)0x0042C870;
void DrawCenteredText(const char* text, int x, int y) {
void* app = *(void**)0x005341E0;
void* font = *(void**)((char*)app + 0x318);
if (!font) return;
FontDrawCentered(
font, // ECX = Font*
(char*)text, // text
x, y, // center position
(void*)0, // unused (auto-filled with 0x4CF300)
1.0f, 1.0f, 1.0f, 1.0f // RGBA (only used if font scale != 1.0)
);
}
6.3 Direct assembly call (for inline asm in DLL)
If calling from inline assembly in a bass.dll proxy:
; Example: Call UI_DrawTextShadow_Wrapper(font, "Hello", 400, 300, 3, 3, ...)
; ECX = Font*, 15 stack params
push_immediate 1.0 ; param_15: shadow_alpha (0x3f800000)
push_immediate 0.0 ; param_14: shadow_blue (0x0)
push_immediate 0.0 ; param_13: shadow_green (0x0)
push_immediate 0.0 ; param_12: shadow_red (0x0)
push_immediate 0x4cf300 ; param_11: shadow vtable (or 0 for wrapper)
push_immediate 1.0 ; param_10: text_alpha
push_immediate 1.0 ; param_9: text_blue
push_immediate 1.0 ; param_8: text_green
push_immediate 1.0 ; param_7: text_red
push_immediate 0x4cf300 ; param_6: text vtable (or 0 for wrapper)
push_immediate 3 ; param_5: shadow_dy
push_immediate 3 ; param_4: shadow_dx
push_immediate 300 ; param_3: y
push_immediate 400 ; param_2: x
push_offset hello_str ; param_1: text
mov ecx, [font_ptr] ; ECX = Font*
call dword ptr [0x00409B90] ; UI_DrawTextShadow_Wrapper
; No add esp needed — function cleans 0x3C bytes via RET 0x3C
7. Font Object Access Paths (for reference)
Different game contexts access the font through different object chains:
7.1 From Gadget-derived objects (Board, Menu, etc.)
All Gadget-derived objects store the App pointer at +0x878:
this+0x878 = App
Font = *(App + 0x318) // or 0x31C, 0x324, 0x328, 0x320
Verified call sites:
ArenaBoard_Render(0x421910):MOV ECX,[ESI+0x878]; MOV ECX,[ECX+0x328]TourneyMenu_Render(0x00450AF0):UI_DrawTextShadow(*([this+0x878]+0x318), ...)HighScoreEntry_Render(0x0042BD40):*([this+0x878]+0x318)TourneyContinueDialog_Render(0x00445F50):*([this+0x878]+0x318)OkayDialog_ctor(0x00440E70):*([this+0x878]+0x318)
7.2 From SimpleMenu-derived objects
SimpleMenu caches the font pointer at +0x87C:
this+0x87C = *(App + 0x318) // set in SimpleMenu_ctor (0x00448F20)
Verified: UIList_Render (0x449D40) line: MOV EBP,[EDI+0x87C]
7.3 From ConfirmMenu_Ctor objects (RaceGoalReached)
this+0x0C = [parent+0x878] = App
Font = *(App + 0x318)
Verified: ConfirmMenu_Render (0x44CD10): MOV EAX,[ESI+0x0C]; MOV ECX,[EAX+0x318]
7.4 From CreditsScreen
this+0xCDC = App (stored in ConfirmMenu_ctor)
Font = *(App + 0x318)
7.5 Direct from global App
void* g_App = *(void**)0x005341E0;
Font = *(void**)((char*)g_App + 0x318);
This is the most reliable approach for DLL mods — no need to trace through
object hierarchies.
8. Changing Font Scale for Colored Text
By default, font scale (Font+0x428) = 1.0f, which causes Font_DrawGlyph
to use the fast Sprite_DrawRect path that ignores color params. To get
colored text:
// Temporarily change font scale to enable colored rendering
float* font_scale = (float*)((char*)font + 0x428);
float old_scale = *font_scale;
*font_scale = 1.2f; // any value != 1.0 triggers the colored path
FontDrawCentered(font, text, x, y, 0, r, g, b, a);
*font_scale = old_scale; // restore
The game itself does this in HighScoreEntry_Render (0x0042BD40):
*(float*)(font + 0x428) = 0x3f400000; // 0.75f — shrink for subtitle
// ... draw text ...
*(float*)(font + 0x428) = 0x3f800000; // 1.0f — restore
9. String Formatting
Use AthenaString_SprintfToBuffer (0x004BAE43) for safe formatting:
typedef void (*AthenaSprintf_t)(char* buffer, const char* fmt, ...);
AthenaSprintf_t AthenaSprintf = (AthenaSprintf_t)0x004BAE43;
char buf[256];
AthenaSprintf(buf, "Score: %d", score);
DrawHUDText(buf, 10, 10);
10. Practical Example: HUD Timer Overlay
// In bass.dll proxy — hook during render frame
void OnRenderFrame() {
void* app = *(void**)0x005341E0;
if (!app) return;
void* font = *(void**)((char*)app + 0x318); // showcardgothic28
if (!font) return;
// Get scene timer (arena mode: Scene+0x47AC)
void* scene = *(void**)((char*)app + 0x184);
if (!scene) return;
int timer = *(int*)((char*)scene + 0x47AC);
float seconds = timer / 60.0f;
char buf[64];
typedef void (*Sprintf_t)(char*, const char*, ...);
Sprintf_t Sprintf = (Sprintf_t)0x004BAE43;
Sprintf(buf, "TIME: %.1f", seconds);
// Draw using wrapper (simplest API)
typedef void (__thiscall *DrawText_t)(void*, char*, int, int, int, int,
void*, float, float, float, float, void*, float, float, float, float);
DrawText_t DrawText = (DrawText_t)0x00409B90;
DrawText(font, buf, 400, 20, 3, 3,
0, 1.0f, 1.0f, 1.0f, 1.0f, // white text
0, 0.0f, 0.0f, 0.0f, 1.0f); // black shadow
}
11. Quick Reference
| Function | Address | Params (stack+this) | RET | Purpose |
|---|---|---|---|---|
UI_DrawTextShadow_Wrapper |
0x00409B90 |
15+1 | 0x3C |
Best for mods — auto-fills vtable |
UI_DrawTextCentered |
0x00409C60 |
15+1 | 0x3C |
Centered + shadow |
UI_DrawTextShadow |
0x004012C0 |
15+1 | 0x3C |
Raw shadowed text |
Font_DrawCentered |
0x0042C870 |
8+1 | 0x20 |
Simplest — centered, no shadow |
Font_DrawGlyph |
0x00457440 |
8+1 | 0x20 |
Raw per-char rendering |
Font_MeasureText |
0x00456E20 |
1+1 | 0x04 |
Measure string width |
Font_DrawGlyph3D |
0x00457690 |
18+1 | 0x48 |
3D world-space text (complex) |
LoadFont |
0x00457130 |
2+1 | — | Load a font from disk |
AthenaString_SprintfToBuffer |
0x004BAE43 |
— | — | Safe printf |
UIList_SetTextByName |
0x0044A8B0 |
— | — | Change menu item text |
UIList_AddItem |
0x004497F0 |
— | — | Add menu item |
Font offsets from App (0x005341E0)
| Offset | Font | Purpose |
|---|---|---|
+0x318 |
showcardgothic28 | Main title / UI text |
+0x31C |
showcardgothic14 | Small label text |
+0x320 |
showcardgothic16 | Info text |
+0x324 |
arialnarrow12bold | UI detail text |
+0x328 |
showcardgothic72 | Race timer digits |
Font struct key offsets
| Offset | Type | Field |
|---|---|---|
+0x428 |
float |
Scale (1.0 = no color, ≠1.0 = colored) |
+0x424 |
int |
Line height |
+0x42C |
char[] |
Per-glyph table start |
Static identity transform
| Address | Value | Purpose |
|---|---|---|
0x004CF300 |
vtable+floats | Default color/transform struct |
12. Common Pitfalls
-
Wrong
thispointer.UI_DrawTextCenteredand all text functions expect
a Font* asthis(ECX), obtained via*(App + 0x318). Passing the App,
Scene, GraphicsDevice, or any other pointer will crash inFont_DrawGlyph. -
Too many/few params.
UI_DrawTextCentered/UI_DrawTextShadowtake 15
stack params (RET 0x3C).Font_DrawCentered/Font_DrawGlyphtake 8 (RET 0x20).
Mismatching the count corrupts the stack. -
Color struct vtable not set — DEBUNKED. Params 6 and 11 of
UI_DrawTextShadow/UI_DrawTextCenteredare overwritten internally with
0x4CF300. You can pass 0 for these params. They are NOT read from the
caller's stack. (See §5.3 for disassembly proof.) -
Colors ignored at scale 1.0. When
Font+0x428 == 1.0f, the fast path
(Sprite_DrawRect) is used and color params are ignored. Set scale ≠ 1.0
temporarily for colored text (see §8). -
Color struct passed as pointer instead of 4 floats. If your
CallMethod
wrapper receives aColorstruct, it may push 1 pointer instead of 4 floats.
This gives 9 stack params instead of 15 → stack corruption viaRET 0x3C.
Always expandColorinto 4 individualfloatarguments. (See §5.5.) -
Font not loaded. Font pointers at
App+0x318etc. are NULL until the
resource loader completes. Always null-check before drawing. -
Calling convention. These are
__thiscall— Font* goes in ECX, params
go on stack right-to-left, callee cleans the stack (RET N). From C, use
__thiscalltypedefs or inline assembly.
🔗 Related Documents
UI/Menu System
UI/Menu System
Hamsterball's UI is a hierarchical menu system built on top of the SceneObject
framework. All menus are SceneObjects added to the scene graph. The system uses
a SimpleMenu base class with UIList widget for vertical scrolling lists.
Menu Class Hierarchy
SceneObject (base)
└── SimpleMenu (0x448F20) — base menu with UIList
├── MainMenu (0x4D3F30) — "LET'S PLAY!", HIGH SCORES, OPTIONS, CREDITS, EXIT
├── DifficultyMenu (0x4D40A8) — PIPSQUEAK, NORMAL, FRENZIED!
├── PauseMenu (0x4D4120) — RESUME, OPTIONS, ABORT RACE
├── PauseArenaMenu (0x430330) — pause for ArenaBoard modes
├── PracticeMenu (0xD18 bytes) — level select for practice
├── TimeTrialMenu (0xD18 bytes) — level select for time trial
├── ArenaMenu (0x42FC40) — ArenaBoard arena select
├── MPMenu (0x4D3F30, 0xCDC bytes) — multiplayer party setup
├── HighScoreMenu (0x42B290) — high scores display
├── OptionsMenu — graphics/sound/controls
├── KeyRemapMenu (0x4D4340) — key binding screen
├── GraphicsOptionsMenu (0x441E70) — quality settings
└── GameSelectionScreen (0x42E060) — tournament results
Dialog Classes
SceneObject (base)
├── OkayDialog (0x440E70, 0x8A8 bytes) — simple message + OK button
├── ConfirmMenu (0x4C780, 0x8A8 bytes) — YES/NO confirmation
├── QuitDialog (0x43E30) — quit race confirmation + continue tourney
├── QuitAbortDialog (0x45320) — abort race dialog
├── QuitToDesktopDialog (0x44790) — exit to desktop confirmation
├── SaveTourneyDialog (0x4FD60) — save tournament progress
├── TourneyContinueDialog (0x45E60, 0x8BC bytes) — resume saved tournament?
└── RegisterDialog (0x476B0) — serial key registration
SimpleMenu Base (0x448F20)
The fundamental menu class. All game menus inherit from this.
Key Offsets (SimpleMenu and subclasses)
| Offset | Type | Field |
|---|---|---|
| +0x868 | char* | Title text (e.g. "Main Menu", "Pause Menu") |
| +0x878 | App* | App pointer (set via SimpleMenu_ctor param) |
SimpleMenu_ctor Flow
- Call SceneObject base constructor
- Set vtable pointer for subclass
- Set title text at
this+0x868 - Add menu items via
UIList_AddItem(this, display_text, command_text, colors...) - Add spacers via
UIList_AddSpacer(this, pixel_height)
UIList Widget System
UIList Functions
| Address | Function | Description |
|---|---|---|
| 0x4492D0 | UIList_AddItem | Add selectable item to list |
| 0x4497F0 | UIList_AddItem (alt) | Add item with format string |
| 0x4490F0 | UIList_AddItemWithFormat | Add formatted item |
| 0x449430 | UIList_AddSpacer | Add vertical spacer |
| 0x449750 | UIList_ActivateCurrentItem | Click current selection |
| 0x449C20 | UIList_HandleKeyNav | Keyboard/gamepad navigation |
| 0x44A570 | UIList_Layout | Recalculate item positions |
| 0x449D40 | UIList_Render | Draw all items |
| 0x4494D0 | UIList_ScrollUpdate | Handle scroll animation |
| 0x44A970 | UIList_SetColorsByName | Set item colors by command |
| 0x44A8B0 | UIList_SetTextByName | Set item text by command |
UIListItem (0x4490A0)
Each item in the list stores:
- Display text (shown on screen)
- Command text (dispatched on click, e.g. "PLAY", "BACK", "1PT")
- Color matrix (4x4 Scale for tinting)
- Spacer flag + height
Menu Dispatch Tables
Main Menu (0x42DE50)
| Display | Command | Handler |
|---|---|---|
| "LET'S PLAY!" | "PLAY" | GameSelectionManager_ctor |
| "HIGH SCORES" | "HS" | HighScoreMenu_ctor |
| "OPTIONS" | "OP" | OptionsMenu |
| "CREDITS" | "CR" | Credits screen |
| "REGISTER GAME" | "RG" | RegisterDialog (only if not registered, App+0x200==0) |
| (dynamic) | "MG" | Mini-game button (only if App+0x918 && App+0xB24) |
| "EXIT TO DESKTOP" | "EXIT" | QuitToDesktopDialog |
Difficulty Menu (0x42E220)
| Display | Command | Difficulty Value |
|---|---|---|
| "PIPSQUEAK" | "EASY" | 0 (easiest, +1000ms bonus) |
| "NORMAL" | "NORMAL" | Standard |
| "FRENZIED!" | "HARD" | 1 (hardest, +500ms bonus) |
| (spacer) | ||
| "PREVIOUS" | "BACK" | Return to previous menu |
Title: "CHOOSE A DIFFICULTY!"
Pause Menu (0x42E4B0)
| Display | Command | Handler |
|---|---|---|
| "RESUME" | "RESUME" | Unpause game |
| "OPTIONS" | "OP" | Options submenu |
| (spacer) | ||
| "ABORT RACE" | "BACK" | Quit current race |
Title: "Pause Menu"
ArenaBoard Menu (0x433AC0 — GameSelectionManager)
See RUMBLEBOARD_SYSTEM.md for full dispatch table.
Play Mode Selection (from GameSelectionManager)
| Display | Command | Mode |
|---|---|---|
| 1-Player Tournament | "1PT" | Tournament, is_mirror=0 |
| 1-Player Mirror Tournament | "1PMT" | Tournament, is_mirror=1 |
| 1-Player Practice | "1PP" | Practice, is_tournament=0 |
| 1-Player Time Trial | "1PTT" | TimeTrial, is_tournament=0 |
| Multiplayer Party | "PARTY" | MP mode |
Menu Color System
Each UIList_AddItem takes a 4x4 matrix for color tinting. Common color patterns:
| Pattern | Hex Values | Usage |
|---|---|---|
| Highlight (bright) | 1.0, 1.0, 0.7, 1.0 | Selected item |
| Normal | 1.0, 1.0, 1.0, 1.0 | Standard item |
| Dimmed | 0.75, 0.75, 0.5, 1.0 | Less important item |
| Warning (red) | 1.0, 0.5, 0.5, 1.0 | Destructive action (Abort/Exit) |
| Dim red | 0.75, 0.5, 0.5, 1.0 | Less important destructive |
| Pipsqueak | 0.7, 1.0, 0.7, 1.0 | Green tint (easiest) |
| Frenzied | 1.0, 0.5, 0.5, 1.0 | Red tint (hardest) |
| Register | 1.0, 1.0, 0.0, 1.0 | Yellow (limited time) |
App Menu State
| App Offset | Type | Field |
|---|---|---|
| +0x184 | void* | scene object list (for Scene_AddObject) |
| +0x224 | void* | Current main menu pointer |
| +0x235 | byte | is_tournament (0=practice/trial, 1=tournament) |
| +0x236 | byte | is_mirror (0=normal tracks, 1=mirror tracks) |
| +0x237 | byte | tournament_complete flag |
| +0x200 | byte | is_registered (1=registered, show "REGISTER" if 0) |
| +0x5E8 | int | P1 time display |
| +0x688 | int | P2 time display |
| +0x728 | int | P3 time display |
| +0x7C8 | int | P4 time display |
| +0x918 | byte | has_mini_game (show "MG" button) |
| +0xB24 | char* | mini_game_label (dynamic button text) |
App Frame Lifecycle
App_FrameTick (0x46C9E0)
Called each frame when game is NOT paused (App+0x159 == 0):
vtable[0x60]()— SceneObject::Tick (update current scene objects)MeshWorld_CallVtable34(scene, param)— Update mesh worldMusicDevice_MuteToggle(param)— Handle audio focus
App_CompleteRace (0x425F90)
Called when a race finishes:
- If
App+0x704 != 0(race in progress):- Increment
App+0x7C8(race completion counter) - Call
scene->vtable[0xFC](0, 13, 1)— D3D surface operation 13 - Call
scene->vtable[0xFC](0, 14, 1)— D3D surface operation 14 - Clear
App+0x704(race no longer in progress)
- Increment
App_Is2PMode (0x427910)
Checks if 2-player mode is active:
bool App_Is2PMode(App *app) {
return ((app[app->p1_offset + 0xC] | app[app->p2_offset + 0xC]) >> 7) & 1;
}
Dialog Details
RegisterDialog (0x476B0)
Serial key registration dialog. Features:
- Text input for serial key
RegisterDialog_ValidateSerial(0x46B80)— key validationRegisterDialog_HandleInput(0x475A0)— character input processingRegisterDialog_HandleKey(0x48890)— keyboard handler- "REGISTER GAME" appears on Main Menu only when
App+0x200 == 0
OkayDialog (0x440E70)
Simple message display dialog:
OkayDialog_ctor(new(0x8A8), app, title_text, message_text, display_time_ms);
Used for error messages, unlock notifications, etc.
TourneyContinueDialog (0x45E60)
Appears when tournament save file exists (DATA\TOURNAMENT.SAV):
- "Continue saved tournament?" prompt
- On continue: loads saved state
- On new game: starts fresh tournament
Related Functions
| Address | Name | Purpose |
|---|---|---|
| 0x448F20 | SimpleMenu_ctor | Base menu constructor |
| 0x42DE50 | MainMenu_ctor | Main menu setup |
| 0x42E220 | DifficultyMenu_ctor | Difficulty selection |
| 0x42E4B0 | PauseMenu_Ctor | In-game pause |
| 0x433AC0 | GameSelectionManager | Title screen dispatcher |
| 0x4492D0 | UIList_AddItem | Add menu item |
| 0x449430 | UIList_AddSpacer | Add vertical gap |
| 0x449C20 | UIList_HandleKeyNav | Input navigation |
| 0x449D40 | UIList_Render | Draw menu |
| 0x440E70 | OkayDialog_ctor | Message dialog |
| 0x443E30 | QuitDialog_ctor | Race quit dialog |
| 0x44790 | QuitToDesktopDialog_Ctor | Exit confirmation |
| 0x45E60 | TourneyContinueDialog_Ctor | Resume tournament |
| 0x476B0 | RegisterDialog_ctor | Serial key entry |
| 0x46B80 | RegisterDialog_ValidateSerial | Key validation |
🔗 Related Documents
Unbreakable + 6x Size Mod
Unbreakable + 6x Size Mod
Info
- File:
bass.dll(proxy) - Effects: No fall damage + 6x ball size + no pause
- Android-safe: No IAT hooks, no GetTickCount, no threads
What it does
Merges three mods into one DLL:
1. No Pause (3 patches)
Disables ESC/right-click pause via 3 single-byte JZ→JMP patches.
2. 6x Ball Size (3 patches)
- Ball_ctor2 default radius: 27.0 → 162.0
- Player ball spawn radius: 26.0 → 156.0
- CreateBadBall SIZE: code cave multiplies by 6.0f (pure FPU asm, no C calls)
3. Unbreakable Ball (8 patches from XRow's CEA)
- 3× early RET (Ball_Shatter, variant, Ball_FallDeath)
- 5× NOP (prevent shatter flag + is_active + fall timer writes)
Winlator Safety
- No IAT hooks (GetTickCount crashes Android)
- No background threads — all patches applied in DllMain
- Code cave is pure FPU assembly (FSTP/FLD/FMUL/RET)
- All patches restored on DLL_PROCESS_DETACH
Build
i686-w64-mingw32-gcc -shared -o bass.dll unbreakable_6x.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Installation
- Rename original
bass.dlltobass_real.dll - Copy mod
bass.dllto game folder - On Android/Wine: set DLL override to
nativeforbass.dll
🔗 Related Documents
Unbreakable Ball Mod
Unbreakable Ball Mod
Info
- File:
bass.dll(proxy) - Effect: Ball never shatters — no fall damage
- Android-safe: No IAT hooks, no code caves, no threads for byte patches
- Translated from: XRow's CEA script
What it does
Patches 8 points in Hamsterball.exe to prevent the ball from shattering:
| # | Address | Original | Patch | Effect |
|---|---|---|---|---|
| 1 | 0x408D70 | 6A FF 64 A1 (SEH frame) |
C3 90 90 (RET) |
Ball_Shatter early return |
| 2 | 0x409050 | 6A FF 64 A1 (SEH frame) |
C3 90 90 (RET) |
Shatter variant early return |
| 3 | 0x409480 | 6A FF 64 A1 (SEH frame) |
C3 90 90 (RET) |
Ball_FallDeath early return |
| 4 | 0x40C761 | 88 85 68 07 00 00 |
6× NOP | Prevent is_active=0 write |
| 5 | 0x40C767 | C6 85 E9 02 00 00 01 |
7× NOP | Prevent shatter flag=1 write |
| 6 | 0x40F226 | C6 86 68 07 00 00 00 |
7× NOP | Prevent is_active=0 write |
| 7 | 0x40F22D | C6 86 E9 02 00 00 01 |
7× NOP | Prevent shatter flag=1 write |
| 8 | 0x4075C9 | FF 86 EC 02 00 00 |
6× NOP | Prevent fall timer increment |
How it works
- Uses
VirtualProtectto make code pages writable - Writes byte patches directly (NOP or RET)
- Verifies original bytes before patching (won't double-patch)
- Restores original bytes on DLL unload
- All pointer accesses guarded by
IsBadReadPtr
Build
i686-w64-mingw32-gcc -shared -o bass.dll unbreakable_ball.c -lwinmm \
-Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
Installation
- Rename original
bass.dlltobass_real.dll - Copy mod
bass.dllto game folder - On Android/Wine: set DLL override to
nativeforbass.dll
🔗 Related Documents
Universal Ref Loader
Universal Ref Loader — Warm-Up Slot Swap Test
Date: 2026-06-23
Mod: universal-ref-loader v3 (bass.dll proxy, 94747 bytes)
Test method: Each race level's MESHWORLD file was copied into Level1.MESHWORLD (Warm-Up race slot), then the Warm-Up Time Trial race was loaded. The game process was checked for survival 8 seconds after race start.
Important limitation
Screenshots are black on Wine/llvmpipe (the mod DLL causes rendering to not produce visible output on software rendering). The mod DLL's hook at 0x0040C4BA interferes with D3D8 rendering on llvmpipe — the game window stays black. However, the game process continues running, which means the ref loading code (Scene_CreateDynamicObjects → vtable[33] hook) executes without crashing.
On real Windows with a GPU, rendering works normally — the hook only intercepts the factory dispatch, not the rendering pipeline.
Results
| Level | Race | Object Refs | Result |
|---|---|---|---|
| L3 | Intermediate | BRIDGE, MOUSETRAP | ✅ ALIVE |
| L4 | Dizzy | GLUEBIE, TIPPER, WATERWHEEL, SWIRL, TARBUBBLE, SIGN-TARPIT | ✅ ALIVE |
| L5 | Tower | CATAPULT, TRAPDOOR, DRAWBRIDGE, MACE, WINDMILL, CHOMPER, TURRET | ✅ ALIVE |
| L6 | Up | SPEEDCYLINDER, LIFTER, TIMEBUTTON | ✅ ALIVE |
| L7 | Neon | DFLOOR, TRODE, NEONPLATFORM | ✅ ALIVE |
| L8 | Expert | BONK, FAN, SAWBLADE, BRIDGE, JUDGE, BELL | ✅ ALIVE |
| L9 | Odd | LIFTER, LAUNCH | ✅ ALIVE |
| L10 | Toob | SPINNY, SAW, FALLOUT1, BLOCKDAWG | ✅ ALIVE |
| L11 | Wobbly | WOBBLY, WAVY | ✅ ALIVE |
| L12 | Glass | SMASHER | ✅ ALIVE |
| L13 | Sky | PILLAR, MAGNIFYER, POPCYLINDER, TRAPDOOR | ✅ ALIVE |
| L14 | Master | BBRIDGE, BLOCKDAWG, BONK, BRIDGE, CATAPULT, GLUEBIE, POPCYLINDER, TIPPER | ✅ ALIVE |
| L15 | Impossible | LOOPER, GEAR, BIGGEAR, ROTATOR, PENDULUM | ✅ ALIVE |
13/13 levels passed — no crashes.
All 46 unique object types across 13 race levels loaded without crashing when loaded in the Warm-Up race slot. The Warm-Up board constructor does not load any object meshes (it has no object refs), so the mod's universal factory dispatch hook was responsible for loading all objects via the Arena factory fallback + JIT mesh injection.
What "ALIVE" means
- Game process survived 8+ seconds after entering the race
- No crash during Scene_CreateDynamicObjects (where the hook fires)
- No crash during factory dispatch for each ref point
- No crash during JIT mesh loading (MeshWorld_ctor, CollisionLevel_ctorWithLevel)
- No crash during clone-on-return for static-mesh objects
What we couldn't verify on Wine
- Visual rendering (black screen on llvmpipe)
- Whether objects appear at correct positions
- Whether object behaviors work (catapults, tippers, etc.)
- These require testing on real Windows with a GPU
🔗 Related Documents
Universal Safespots
Universal Safespots
A bass.dll proxy mod that adds a "universal" SAFESPOT type to Hamsterball.
What It Does
Place a SAFESPOT(*) reference point in your level's MESHWORLD file. When the ball
has an active checkpoint filter (e.g. (B) from an E:SAFESWITCH(B) trigger), normal
SAFESPOTs with non-matching letters are rejected by the respawn search. This mod
intercepts that rejection and checks if the SAFESPOT name contains (*). If found,
the SAFESPOT is accepted regardless of the current filter — it competes with
matching SAFESPOTs purely on distance.
How It Works
In Ball_FindClosestRespawnPoint (0x405190), the filter check at 0x405894 does:
jne 0x4058C2 ; reject if strnicmp != 0 (letters don't match)
The mod replaces this 2-byte jne (plus 3 bytes of the accept path that follow it)
with a 5-byte JMP to a code cave. The code cave:
- If
strnicmpmatched (eax==0): accept (original behavior) - If
strnicmpdidn't match: callsstrstr(safespot_name, "(*)")- If found: accept (override — universal safespot)
- If not found: reject (original behavior)
The strstr function (0x4BAC20) is statically linked in the EXE and preserves edi.
Hook Details
| Address | Original Bytes | Description |
|---|---|---|
| 0x405894 | 75 2C |
jne 0x4058C2 (reject non-matching filter) |
| 0x405896 | 8B 07 |
mov eax, [edi] (accept path: load safespot name) |
| 0x405898 | 68 90 F4 4C 00 |
push 0x4CF490 (accept path: push "[Z]" string) |
5 bytes at 0x405894 are replaced with E9 XX XX XX XX (JMP to code cave).
Usage
- Rename the original
bass.dlltobass_real.dllin the game folder - Copy this mod's
bass.dllinto the game folder - Add
SAFESPOT(*)reference points in your level's MESHWORLD file - The universal SAFESPOTs will be accepted regardless of active checkpoint filter
Safety
- No threads, no IAT hooks
- No C function calls from the code cave (pure assembly, calls statically linked
strstr) [Z]/[X]gravity checks still apply after acceptance- Multiplayer proximity checks (2P mode) still apply
Compilation
i686-w64-mingw32-gcc -shared -o bass.dll universal_safespots.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
🔗 Related Documents
unlimited_tris
unlimited_tris
Removes triangle count limit on custom levels
Files
unlimited_tris_bass.c— C source codebass_unlimited.dll— Compiled DLL (PE32 i386)
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dll(or renamed DLL) into the game folder - Launch Hamsterball
🔗 Related Documents
Unreleased & Hidden Features
Hamsterball Unreleased & Hidden Features
Reverse-engineered from Hamsterball.exe (V3.6.c, PE32 i386) via Ghidra decompilation and string analysis.
1. Party Race / Local 2-Player Split-Screen
Status: Fully coded, menu-accessible in retail but possibly cut from some distributions
A complete 2-player local mode exists in the binary:
- Functions:
App_Is2PMode(0x00427910),App_Start2PRace(0x00429230),PartyMenu_ctor(0x0042fc10) - Strings:
"PARTY GAMES!","CHOOSE A PARTY RACE!","PARTY RACE (2P ONLY)","THE PARTY RACE REQUIRES THAT PLAYER 1 AND PLAYER 2 BE HUMAN PLAYERS!" - Controls:
2PController1through2PController4— 4 separate control profiles for local multiplayer - Level asset:
Levels\\Level10-2PBridge— a dedicated 2P bridge level for the Master race - Textures:
partyrace.png,Winner2p.png - Menu flag: Sets
App+0x237=1(is_2p_mode),App+0x234=1, and enables player slots at+0x677,+0x717,+0x7b7 - Player 2 status:
"PLAYER 2: %s","PLAYER 2: COMPUTER","PLAYER 2: OFF"— P2 can be human, AI, or disabled
The 2P mode checks player slots (+0xB2C, +0xB30, +0xB34 against value 100) to determine if players 3/4 are active.
2. Mirror Tournament
Status: Unlockable in retail, but hidden behind win condition
A mirrored (reversed) version of the tournament mode:
- Strings:
"MIRROR TOURNAMENT","MirrorTournament","THE MIRROR TOURNAMENT ISN'T UNLOCKED YET! TO UNLOCK THE MIRROR TOURNAMENT, YOU NEED TO WIN A TOURNAMENT AT NORMAL OR FRENZIED DIFFICULTY!" - Mirror textures:
arrow1-mirrored.png,goal-lit-mirrored.png,goal-mirrored.png,goal-round-lit-mirrored.png,goal-round-mirrored.png,mirror.png,sign-bewarethetar-mirrored.png - Render functions:
Level_RenderWithMirror4(0x00413fc0),Level_RenderWithMirror5(0x004151e0) — render scenes with mirrored projection matrices (flips projection on one axis then restores) - Unlock check:
GameSelectionManager(0x00433ac0) checks for"LOCKED"string to show the unlock dialog
The mirror rendering works by adjusting projection matrix offsets (+0x790/+0x794) by a delta value, rendering 4 mirror planes, then restoring.
3. "NetworkConnection" — Actually an InputDevice (Not Networking)
Status: Misleading name — this is an InputDevice class, NOT network multiplayer
- Function:
NetworkConnection_Ctor(0x0046dfa0) — base InputDevice constructor - Struct layout (20 bytes / 0x14):
+0x00: char* name ("Keyboard","Mouse/Trackball","Not Connected")+0x04: InputHandler* parent+0x08: int type (0=none, 1=keyboard, 2=mouse, 4-7=gamepad 1-4)+0x0C: float sensitivity (1.0f default, 0.0 when not connected)+0x10: void* device_data (keyboard buffer, joystick state, etc.)
- Created in:
App_Initialize_Full— 4 instances atApp+0x550..0x55C, thenInputDevice_SetTypeconfigures each as keyboard/mouse/gamepad1/gamepad2 "Not Connected"is just the default name when no gamepad/joystick is physically plugged in — NOT a network status
WS2_32.dll imports (socket, connect, send, recv, etc.) exist in the binary but are used exclusively by:
- eSellerate DRM — HTTP connections to
store.esellerate.netfor activation/serial verification - Raptisoft crash reporter — sends error reports to Raptisoft's server
There is no online multiplayer code in Hamsterball. The MPMenu ("MP Menu" / App_StartMPRace) stands for Multiplayer Party (local), offering "PARTY RACE (2P ONLY)" and "RODENT RUMBLE (1-4P)" — both local modes.
4. Secret Objects & Arena Unlock System
Status: Fully implemented, part of tournament progression
Hidden collectibles in race levels that unlock arenas:
- Function:
CreateSecretObjects(0x0040baa0) — factory forSECRETandSECRETUNLOCKobjects - Types:
N:SECRET— marks a secret spot found (callsRotator_MarkTriggeredat 0x004371f0, sets+0x10E4=1)N:UNLOCKSECRET/SECRETUNLOCK— triggers arena unlock (callsCheckArenaUnlockat 0x0040aba0)
- Level files:
Levels\Secret,Levels\Secret-Unlock - Unlock mechanism:
CheckArenaUnlockuses a switch on player profile index (profile+8, cases 4-15) to set unlock flags at offsets+0x85Athrough+0x868on the App struct - Unlock text:
"THIS ARENA ISN'T UNLOCKED YET! TO UNLOCK %s ARENA, YOU NEED TO FIND THE SECRET UNLOCK SPOT IN THE %s RACE DURING A NORMAL OR FRENZIED TOURNAMENT GAME!" - Gate: Only works when
App+0x23C != 0(not Pipsqueak difficulty) ANDApp+0x234 == 0(in tournament mode) AND profile+0x11 == 0
Secret_ctor (0x0043dfb0) inherits from Stands (static mesh object), allocates a CollisionLevel, and sets up timer-based update/render callbacks.
5. Tournament System (Full Campaign)
Status: Fully implemented, retail feature
A complete tournament mode with progression, saving, and multiple difficulty levels:
- Manager:
GameSelectionManager(0x00433ac0) — handles menu routing: BACK → MainMenu, LOCKED → unlock dialog, 1PT → single player tournament - Save file:
DATA\TOURNAMENT.SAV/DATA\tournament.sav - Save/Load:
TourneyMenu_LoadSaveAndShow(0x004265a0),TourneyMenu_WriteSave(0x004264b0),Tourney_SaveTournament(0x00446730) - Continue dialog:
TourneyContinueDialog_Ctor(0x00445e60) — "CONTINUE TOURNAMENT?" prompt - Rollback:
"YOU HAVE LOST THE TOURNAMENT. IN ORDER TO RESUME, YOU NEED TO SELECT 'ROLLBACK' ON THE MENU!" - Difficulty: Pipsqueak / Normal / Frenzied — affects time bonuses and unlock eligibility
- Race unlock text:
"THIS RACE ISN'T UNLOCKED YET! TO UNLOCK %s RACE, YOU NEED TO REACH IT WHILE PLAYING A NORMAL OR FRENZIED TOURNAMENT GAME!" - Rankings: Final ranking by score, with rank textures at
textures\ranks\%d.jpg - Tournament textures: Per-race preview images (
tourney-beginner.pngthroughtourney-impossible.png) - Music: Tournament adjusts music tempo (
MusicPlayer_SetTempoScale1.0 for main, 0.5 for secondary)
6. Demo/Trial Limitations
Status: Embedded DRM — retail had a demo version
- Free play counter:
"You have %d free play remaining!"— limited number of plays before purchase required - Demo end:
"You have reached the end of the demo version of Hamsterball! But, if you buy now, you can continue, right here, right now! Or, click cancel to return to the main menu." - Purchase prompts:
"BUY HAMSTERBALL AND YOU CAN SAVE YOUR HIGH SCORES!","CLICK HERE TO BUY!","CLICK HERE TO REGISTER HAMSTERBALL!" - Demo slowdown:
"DEMO, THE SLOWER THE GAME"— demo version intentionally slows gameplay - Tournament save lock:
"DID YOU KNOW THAT IF YOU BUY HAMSTERBALL, YOU CAN CONTINUE YOUR TOURNAMENT LATER? YOU CAN BUY NOW, AND PICK UP WHERE YOU LEFT OFF NEXT TIME YOU PLAY!" - High score lock: Demo users cannot save high scores —
"BUY HAMSTERBALL AND YOU CAN SAVE YOUR HIGH SCORES!" - Demo textures:
demo.png
7. eSellerate DRM System
Status: Embedded third-party DRM (eSellerate by Esellerate)
A full e-commerce/DRM system is embedded in the binary:
- Activation:
Activate,ActivateSerialNumber,Activate using another computer with web access - Serial/Key:
Enter Activation Key,Activation Key:,Activation URL:,ManualActivation.txt - Manual activation: URL
http://activate.esellerate.net,"Click the URL below to direct your web browser to the manual product activation web site" - Server communication:
cmd=DoHandshake&encryptedClientData=,cmd=GetServerPublicKey - Encryption:
"We encrypt any serial number transmissions and/or product downloads" - Reinstall:
"hold down the Alt key and click the Reinstall button" - Purchase flow:
Purchase,Software\eSellerate\Common\PurchaseInfo,Billing Information (Preview Mode) - Coupons:
COUPON,COUPONERRMSG,COUPONSAVINGS,COUPONTYPE,CROSSSELLGROUPDESC
8. Raptisoft Crash Reporter / Bug Tracker
Status: Fully implemented error reporting system
An embedded crash reporter that sends data to Raptisoft:
- Strings:
"RaptisoftBugTracker","RaptisoftCrashWindow","Raptisoft Utility","*** BEGIN RAPTISOFT SESSION ***","*** END RAPTISOFT SESSION ***" - Error report:
"Sending Error Report...","Error report sent successfully!","Could not send error report:","Error: Could not contact Raptisoft reporting server!" - User prompt:
"%s has encountered an unexpected error! Pressing the 'Send Report' button will send this error report to Raptisoft via the web (an internet connection is required)." - Report format: XML-based —
<ERRORREPORT>,</ERRORREPORT>,<MODULE>,<description> - Safe mode:
"SAFE MODE: %s"/"SAFE MODE: OFF"—SAFEMODEflag,OptionsMenu_UpdateSafeModeText(0x00442630) - Debug:
ASTART-DEBUGstring,DebugSetMute(D3D8 function)
9. Auto-Update System
Status: Fully implemented update checker
- Strings:
"Check For Update","CheckForUpdate","Checking for new updater software","CHECKFORUPDATEMSGTEXT" - Update URL pattern:
?selector=UpdaterStub&vers=— queries Raptisoft server for version updates - Mechanism: Separate updater stub process (
UpdaterStub)
10. Medal / Rank System (Gold/Silver/Bronze/Weasel)
Status: Fully implemented — time-based ranking per level
Each race track has medal time thresholds:
- Tiers: Gold, Silver, Bronze, and a special "Weasel" rank
- Strings:
"GOLD TIME:","SILVER TIME:","BRONZE TIME:","WEASEL'S TIME:","BEST RACE TIME:" - Icons:
gold-icon.png,silver-icon.png,bronze-icon.png,gold-small.png,silver-small.png,bronze-small.png - Golden Weasel: Special top rank —
goldenweasel-icon.png,goldenweasel.png,textures\ranks\weasel.png,weaselbox.png - Rank textures:
textures\ranks\%d.jpg(numbered rank images) - Display:
"YOUR RANK:","FINAL RANKING:","FINAL RANKING BY SCORE:","FINAL SCORE: %.0f" - Registry:
Medalskey
11. Bonus Systems
11a. Glass Break Bonus
- Meshes:
Meshes\GlassBonus,Meshes\GlassBonus-Smashed— a glass panel that breaks - Sound:
sounds\GlassBonus,sounds\bonuspop - Description: "IF YOU CAN MANAGE TO BREAK SOME GLASS HERE, YOU'LL GET A NICE TIME BONUS!"
- Mechanism: Glass level has breakable panels that grant time bonus when shattered
11b. Time Bonus Events
N:TENBONUS1/N:TENBONUS2— bonus time triggers (10-second bonuses)N:EXTRATIME— extra time pickup- Strings:
"Bonus +%d!","Bonus 20 - %d = %d","EXTRA TIME:","SUPER BONUS:","SURVIVAL BONUS:" - Bell bonus: "RING THE BELL ON THE BIG JUMP FOR AN EXTRA FIVE SECONDS!" (Expert race)
11c. Survival Bonus (Arena/Rumble)
"SURVIVAL BONUS:"— points for surviving in Rodent Rumble arena mode
12. Hidden Game Object Types (N: Events)
All named object types found in the binary. Many are well-known, but some are more obscure:
| N: Tag | Level(s) | Description |
|---|---|---|
N:SECRET |
Secret levels | Secret collectible (marks triggered) |
N:UNLOCKSECRET |
Secret-Unlock | Arena unlock trigger |
N:TENBONUS1 |
Various | 10-second time bonus |
N:TENBONUS2 |
Various | Second 10-second time bonus type |
N:EXTRATIME |
Various | Extra time pickup |
N:GLASS |
Glass race | Glass breakable surface |
N:BOUNCE |
Impossible arena | Bounce pad |
N:JUMPFIRST |
Tower | First jump trigger |
N:JUMPSECOND |
Tower | Second jump trigger |
N:NOCONTROL |
Various | Disable player control (cutscene) |
N:ONGEAR |
Master | Gear riding surface |
N:ONROTATOR |
Various | Rotating platform surface |
N:SAWTEETH |
Expert | Saw blade hazard |
N:SPEEDCYLINDER |
Up | Speed boost cylinder |
N:SPINNY |
Expert | Spinning platform |
N:SQUAREWOBBLY |
Wobbly | Square wobbling platform |
N:TRAPDOOR |
Tower/Toob | Trapdoor (falls away) |
N:WHEELEMBED |
Various | Embedded wheel mechanism |
N:NEONPLATFORM |
Neon | Glowing platform |
N:WATERWHEEL |
Toob | Water wheel |
N:SWIRL |
Toob | Swirling flag wave effect |
N:TARPIT |
Toob | Tar pit (slows ball) |
N:MOUSETRAP |
Intermediate | Mouse trap hazard |
N:WAVY |
Various | Wavy platform |
N:WATER |
Toob | Water surface |
Hidden E: (Event) Triggers
Event triggers that fire gameplay effects. Some appear unused or very rare:
| E: Tag | Likely Purpose |
|---|---|
E:OPENSESAME |
Secret door opening (Sesame reference) |
E:SAFESWITCH |
Safety switch toggle |
E:VACPOPOUT |
Vacuum pop-out effect |
E:ZOOP |
Quick movement/teleport effect |
E:SHRINK |
Shrink ball (odd race gravity mechanics) |
E:GROW |
Grow ball |
E:GROWSOUND |
Grow sound effect |
E:GRAVITY |
Gravity flip (odd race) |
E:SWALLOW |
Swallow ball (despawn/kill) |
E:TRAJECTORY |
Set ball trajectory |
E:SHRINKCENTER |
Shrink toward center |
E:LIGHTSOFF / E:LIGHTSON |
Neon race lighting toggle |
E:HEATOFF / E:HEATON |
Heat effect toggle |
E:CATAPULTBOTTOM |
Catapult bottom trigger |
E:DROPLIFT |
Drop lift platform |
E:CALLHAMMER |
Summon BONK (hammer) |
E:HAMMERCHASE |
Activate BONK chase mode |
E:MACETRIGGER |
Mace swing trigger |
E:ALERTJUDGES |
Alert judge objects |
E:SAFESWITCH |
Safe mode switch |
E:PIPERANDOM |
Random pipe selection |
E:TRAPPOP |
Trap pop effect |
E:ACTIVATESAW1 / E:ACTIVATESAW2 |
Activate saw blades |
E:ALERTSAW1 / E:ALERTSAW2 |
Alert saw blades |
E:PEGS / E:NOPEGS |
Show/hide pegs |
E:NODIZZY |
Disable dizzy effect |
E:BRANCH |
Branch path selection |
E:BREAK |
Break object |
E:LIMIT / E:LIMITX / E:LIMITZ / E:LIMITPIPE1 / E:LIMITPIPE2 |
Movement limits |
E:HELPINERTIA / E:UNHELPINERTIA |
Assist/impede ball inertia |
13. BounceBall / FollowBall System
Status: Fully implemented — appears in specific levels
A ball-spawning system where certain objects spawn chasing balls:
BounceBall_Update(0x00440840): Timer-based spawner. After a countdown (initial 120.0 seconds =0x42F00000), spawns aFollowBall:- Searches for
"BallPath"object in the scene viaLevel_FindObjectByName - Reads
"FOLLOWBALLSPOT"from hash table for spawn position - Creates FollowBall with
FollowBall_Ctor(0x0043ebc0) - Sets ball velocity to
(-3.0, 10.0, 0)— launches upward - Sets FollowBall state to
+0x80C = 0xF(chase mode 15) - Adds to ball list (
board+0x29D4) - Plays 3D spawn sound
- Searches for
FollowBall_Update(0x0043ecc0): AI follow/chase logic for spawned ball
The BounceBall has two phases: active (counting down) and inactive (counting up). State stored at +0x439 (timer) and +0x43A (phase flag).
14. Hamster Character Animations
Status: Mesh assets referenced — may be unused or for menu/cutscene
Hamster character model meshes beyond the ball:
Meshes\Hamster-Waiting— idle/standing hamsterMeshes\Hamster-trot1— trot animation frame 1Meshes\Hamster-trot2— trot animation frame 2Meshes\Hamster-trot3— trot animation frame 3Meshes\FunBall— unknown ball variant (possibly bouncy/fun mode)Meshes\RBGlare— ball glare effectMeshes\YellowLink— unknown (possibly chain/link visual)Meshes\dawgshadow/dawgshoe/dawgshoe2— Block Dawg (enemy) shadow and shoe meshes
15. DispatchCollisionEvents Object
Status: Implemented but obscure
- Function:
DispatchCollisionEvents(0x0040c5d0) — dispatches collision events (N:GOAL, N:TARPIT, E:JUMP, E:NODIZZY, etc.) - Event:
E:NODIZZY— triggered when ball enters this zone - Likely used on specific levels where the player would otherwise get dizzy (e.g., after spinning platforms)
16. Rodent Rumble (Arena) — Full 4-Player Mode
Status: Retail feature, fully implemented
Arena combat mode for 1-4 players:
- Strings:
"RODENT RUMBLE (1-4P)","THE RODENT RUMBLE REQUIRES AT LEAST TWO HUMAN OR COMPUTER PLAYERS!" - 13 arenas: Warmup, Beginner, Intermediate, Dizzy, Tower, Up, Expert, Odd, Toob, Wobbly, Neon, Glass, Impossible, Master, Sky
- AI players:
"PLAYER 2: COMPUTER"— CPU-controlled opponents - Scoring: Survival bonus, knockoff count tracking
- Pause:
PauseArenaMenu_ctor(0x00430330) —"Pause Rumble Menu" - Special:
ImpossibleArenaCollisionEvents(0x00418600) has uniqueN:BOUNCEhandling not in other arenas
17. Registry / Config Persistence
Status: Retail — standard config system
Registry keys for persistent game state:
CONTROL1throughCONTROL4— per-player control mappings2PController1through2PController4— 2P mode control mappingsBestTime— per-level best timesMedals— medal/rank achievement trackingSafeMode— safe rendering mode flagMirrorTournament— mirror tournament unlock flagSAFEMODE— safe mode toggle
Summary Table
| Feature | Status | Notes |
|---|---|---|
| Party Race (2P split-screen) | ✅ Implemented | Requires 2 human players, dedicated level asset |
| Mirror Tournament | ✅ Unlockable | Win tournament at Normal/Frenzied |
| Online Multiplayer | ❌ Not present | NetworkConnection is InputDevice, WS2_32 is for DRM/crash reporter |
| Secret Collectibles | ✅ Implemented | Unlock arenas in tournament mode |
| Tournament Campaign | ✅ Retail | Full save/load, 3 difficulties, rollback |
| Demo/Trial Mode | ✅ Embedded | Free play counter, purchase prompts |
| eSellerate DRM | ✅ Embedded | Full activation/serial system |
| Crash Reporter | ✅ Implemented | Sends reports to Raptisoft server |
| Auto-Update | ✅ Implemented | UpdaterStub version check |
| Medal/Rank System | ✅ Retail | Gold/Silver/Bronze/Weasel per level |
| Glass Break Bonus | ✅ Implemented | Glass level time bonus |
| BounceBall/FollowBall | ✅ Implemented | Timed ball spawner with chase AI |
| Hamster Character Meshes | ⚠️ Referenced | Trot animation frames — may be unused |
| NoDizzy Zone | ✅ Implemented | Disables dizzy effect |
| Safe Mode | ✅ Retail | Graphics fallback mode |
Document generated via Ghidra decompilation + string analysis of Hamsterball.exe V3.6.c (md5: 7d25019366b8d7f55906325bd630d7fe). All addresses are RVAs in the loaded image.
🔗 Related Documents
Verified MESHWORLD Ref Points
Verified MESHWORLD Ref Points by Race Level
Source: Parsed directly from the original game's MESHWORLD binary files
Location: originals/installed/extracted/Levels/*.MESHWORLD
Method: Binary parser using the official Raptisoft meshworld format spec
Date: 2026-06-23
What This Document Is
This is the ground-truth list of Section 1 ref point names found in each race level's MESHWORLD file. Every name below was extracted by parsing the actual binary data — not inferred from decompilation or guesswork.
46 unique object types exist across the 15 race levels. This corrects earlier documentation that claimed "75 refs" — that number was inflated by incorrectly counting Section 6 entity names (N:/E:/T: geometry behavior modifiers) as if they were Section 1 ref points, and by inventing names that do not exist in any file.
MESHWORLD Format — Two Separate Systems
A MESHWORLD file has two distinct name systems that should not be conflated:
Section 1: Ref Points (Object Spawn Points)
- Stored at the top of the file as
[u32 count][count × ref_entry] - Each entry:
[string name][float pos.x,z,y][float rot.x,z,y][u32 has_material][material?] - These are object spawn markers — the game reads them and creates game objects via
Board->vtable[33]factory dispatch - Examples:
SPEEDCYLINDER,BONK,GEAR,TIPPER,CATAPULT - Names are stored without N:/E: prefix — bare names only
Section 6: Entity Names (Geometry Behavior Modifiers)
- Stored inside the octree leaf nodes, attached to individual geometry strips
- Format:
[string name]per geom inside a leaf cube - These are not objects — they modify the behavior of the mesh geometry they're attached to
- Examples:
N:GOAL,E:LIMIT,E:JUMP,T:NEONARROW(NOCOLLIDE),E:LIGHTSOFF - Only Neon Race has Section 6 entity names in its race MESHWORLD file. Other race levels have empty Section 6 entity lists (entity names are more common in Arena MESHWORLD files).
Utility Refs (Present in Most/All Levels, Not Game Objects)
These refs appear in Section 1 but are not game objects — they are structural/utility markers:
| Utility Ref | Purpose |
|---|---|
START1-1, START2-1, START2-2 |
Ball spawn points (player 1, player 2 positions) |
FLAG02–FLAG18 |
Checkpoint flags along the race track |
SAFESPOT / SAFEPOS |
Respawn safe spots (with variants like SAFESPOT(A), SAFESPOT(B), SAFESPOT[X], SAFESPOT[Z]) |
SAFESPOT\t |
Tab-terminated safespots (same purpose, formatting variant) |
SECRET / SECRETUNLOCK |
Secret level unlock triggers |
BADBALL <CHASE>...</CHASE><HOME>...</HOME> |
AI ball spawn with XML parameters |
START-DEBUG01–06 / START-DEBUGX |
Debug spawn points |
NOTHING |
Null placeholder ref |
CameraLocus1 |
Camera focus point |
FollowBallSpot |
Camera follow target |
SIGN / SIGN-* |
Sign objects (handled by separate Scene_CreateSigns dispatch) |
Verified Object Refs Per Race Level
L1 — Warm-up Race (Level1.MESHWORLD)
(no object refs — only START, SAFESPOT, and utility refs)
L2 — Beginner Race (LevelCascade.MESHWORLD)
(no object refs — only START, SAFESPOT, FLAG, BADBALL)
L3 — Intermediate Race (Level2.MESHWORLD)
- BRIDGE
- MOUSETRAP
L4 — Dizzy Race (Level3.MESHWORLD)
- SIGN-TARPIT
- TARBUBBLE
- GLUEBIE
- TIPPER
- WATERWHEEL
- SWIRL
L5 — Tower Race (Level4.MESHWORLD)
- CATAPULT
- TRAPDOOR
- DRAWBRIDGE
- MACE
- WINDMILL
- CHOMPER
- TURRET
L6 — Up Race (LevelUp.MESHWORLD)
- SPEEDCYLINDER
- LIFTER
- TIMEBUTTON
L7 — Neon Race (LevelDark.MESHWORLD)
- DFLOOR
- TRODE
- NEONPLATFORM
L8 — Expert Race (Level5.MESHWORLD)
- BONK
- FAN
- FANSLOW
- SAWBLADE
- BRIDGE
- SAW-BREAK
- JUDGE
- BELL
L9 — Odd Race (Level6.MESHWORLD)
- LIFTER
- LAUNCH
L10 — Toob Race (Level8.MESHWORLD)
- SPINNY
- SAW
- FALLOUT1
- SAW2
- BLOCKDAWG
L11 — Wobbly Race (Level7.MESHWORLD)
- WOBBLY
- WAVY
L12 — Glass Race (LevelGlass.MESHWORLD)
- SMASHER
L13 — Sky Race (Level9.MESHWORLD)
- PILLAR
- MAGNIFYER
- POPCYLINDER
- TRAPDOOR
L14 — Master Race (Level10.MESHWORLD)
- BBRIDGE
- BLOCKDAWG
- BONK
- BRIDGE
- CATAPULT
- GLUEBIE
- MOUSETRAP
- POPCYLINDER
- TIPPER
- TARBUBBLE
L15 — Impossible Race (LevelImpossible.MESHWORLD)
- LOOPER
- GEAR
- BIGGEAR
- ROTATOR
- PENDULUM
Reverse Index — Object → Levels
| Object | Race Levels Found In | Count |
|---|---|---|
| BBRIDGE | Master | 1 |
| BELL | Expert | 1 |
| BIGGEAR | Impossible | 1 |
| BLOCKDAWG | Toob, Master | 2 |
| BONK | Expert, Master | 2 |
| BRIDGE | Intermediate, Expert, Master | 3 |
| CATAPULT | Tower, Master | 2 |
| CHOMPER | Tower | 1 |
| DFLOOR | Neon | 1 |
| DRAWBRIDGE | Tower | 1 |
| FALLOUT1 | Toob | 1 |
| FAN | Expert | 1 |
| FANSLOW | Expert | 1 |
| GEAR | Impossible | 1 |
| GLUEBIE | Dizzy, Master | 2 |
| JUDGE | Expert | 1 |
| LAUNCH | Odd | 1 |
| LIFTER | Up, Odd | 2 |
| LOOPER | Impossible | 1 |
| MACE | Tower | 1 |
| MAGNIFYER | Sky | 1 |
| MOUSETRAP | Intermediate, Master | 2 |
| NEONPLATFORM | Neon | 1 |
| PENDULUM | Impossible | 1 |
| PILLAR | Sky | 1 |
| POPCYLINDER | Sky, Master | 2 |
| ROTATOR | Impossible | 1 |
| SAW | Toob | 1 |
| SAW-BREAK | Expert | 1 |
| SAW2 | Toob | 1 |
| SAWBLADE | Expert | 1 |
| SIGN-TARPIT | Dizzy | 1 |
| SMASHER | Glass | 1 |
| SPEEDCYLINDER | Up | 1 |
| SPINNY | Toob | 1 |
| SWIRL | Dizzy | 1 |
| TARBUBBLE | Dizzy, Master | 2 |
| TIMEBUTTON | Up | 1 |
| TIPPER | Dizzy, Master | 2 |
| TRAPDOOR | Tower, Sky | 2 |
| TRODE | Neon | 1 |
| TURRET | Tower | 1 |
| WATERWHEEL | Dizzy | 1 |
| WAVY | Wobbly | 1 |
| WINDMILL | Tower | 1 |
| WOBBLY | Wobbly | 1 |
Total: 46 unique object types across 15 race levels.
Refs That Do NOT Exist in Any Race Level File
The following names were listed in earlier documentation as ref points but do not exist in any original race MESHWORLD Section 1 data. Some are Section 6 entity names (geometry modifiers), others were fabricated:
BUMP,N:BUMP,N:BUMPER— do not exist as Section 1 refs (BUMPER is not a ref point; bumper behavior is set via Section 6 entity names)N:GLASS,N:TENBONUS1,N:TENBONUS2— Section 6 entity names, not Section 1 refsN:SPINNER,N:ONGEAR,N:ONROTATOR,N:BOUNCE— Section 6 entity namesN:SQUAREWOBBLY,N:WAVY,N:SPINNY,N:SAWTEETH— Section 6 entity namesN:JUMPFIRST,N:JUMPSECOND— do not exist in any fileN:WATER,N:TARPIT,N:GOAL— Section 6 entity names (N:GOAL appears only in Neon Race Section 6)E:LAUNCH,E:JUMP,E:ACTION,E:LIMIT,E:BREAK— Section 6 entity namesEDGECYLINDER— does not exist in any race fileT:SPEEDARROW— does not exist as a Section 1 refFLICKNING— does not exist in any race file (appears in Arena Neon only)N:NEONPLATFORM— Section 6 entity nameVAC-IN,VAC-OUT,VAC-VEC— utility refs for camera/vacuum zones in Up Race, not game objects
🔗 Related Documents
Wall Bumpers Mod
Wall Bumpers Mod
All walls act as pinball bumpers — the ball bounces off walls with amplified force.
Installation
- Rename original
bass.dll→bass_real.dllin your Hamsterball folder - Copy the modded
bass.dllinto the game folder - Launch the game
Controls
| Key | Action |
|---|---|
| F8 | Toggle mod on/off (default: ON) |
| F9 | Cycle bumper force (40 → 60 → 80 → 120 → 200) |
Log File
The mod writes bumper_mod.log in the game directory. Check this file to confirm:
- Hook installed successfully
- Per-frame ball tracking data
- Bumper hit events with velocity details
How It Works (v3)
The mod hooks Ball_AI_ChaseNearest (0x408390) — ball vtable[4], called every frame for all balls including the player in race mode.
Detection: Two Methods
-
Velocity Reversal Detection (primary, works in all modes):
Tracks ball position each frame and computes velocity from position delta. If the dot product of consecutive velocity vectors is negative (velocity reversed), the ball hit something — amplify the bounce. -
Collision Entry Scanning (secondary, arena mode only):
Scans the PhysicsObject's collision entry list for type==2 (wall) entries and reads surface normals.
History
- v1: Hooked Ball_FallUpdate (0x408830) — dead code, never called for player
- v2: Hooked Ball_AI_ChaseNearest (0x408390) but scanned collision entries which are EMPTY for player in race mode (Ball_Update never called). Also had BASS proxy stack corruption (void WINAPI name() with zero params).
- v3: Fixed BASS proxy signatures + velocity-reversal detection that works in all modes.
Build
i686-w64-mingw32-gcc -shared -o bass.dll wall_bumper_mod.c \
-lwinmm -Wl,--enable-stdcall-fixup -O2 -static -static-libgcc \
-Wl,--add-stdcall-alias
🔗 Related Documents
Water Physics Mod
Hamsterball Water Physics Mod
Custom water physics for Hamsterball via bass.dll proxy.
Installation
- In your Hamsterball game folder, rename the original
bass.dlltobass_real.dll - Copy the mod
bass.dllandhamsterball_water.iniinto the game folder - Place
E:WATERcollision planes in custom levels (see below) - Run Hamsterball.exe normally
Uninstall
- Delete the mod
bass.dll - Rename
bass_real.dllback tobass.dll
Or run uninstall_water_mod.bat.
How Water Planes Work
Level Setup
In the Raptisoft level editor, add a collision mesh object named E:WATER.
The object needs at least one face (triangle). The Y coordinate of the first
vertex of the first face determines the water surface height.
The mod scans both the collision MeshWorld and visual MeshWorld for objects
named E:WATER when a level loads.
Fallback: INI Water Planes
If your level's collision data doesn't expose E:WATER objects (or you want
to test without editing a level), you can specify water plane Y coordinates
in hamsterball_water.ini:
[WaterPlanes]
Count=1
Y0=100.0
Physics Behavior
When the ball touches a water plane, the following physics apply:
-
Entry Damping: On first contact while falling, vertical speed is
reduced by 30% (configurable viaEntryDamping). -
Drag: A small per-frame velocity reduction on all axes (
Drag). -
Buoyancy: An upward force proportional to how deep the ball is
submerged. The force increases linearly from 0 (just touching surface)
to 2× gravity (fully submerged). -
Equilibrium: At half-submerged, buoyancy exactly cancels gravity,
so the ball floats with zero net vertical force at the surface. -
Horizontal Dampening: Extra drag on X/Z axes, slightly lowering
the maximum horizontal speed in water (HorizontalDrag). -
No Vertical Speed Cap: The engine's own max velocity remains
unchanged — no artificial cap is added.
Configuration
See hamsterball_water.ini for all options with descriptions.
Debug Log
With Debug=1 in the INI, the mod writes a log file (<a href="#14995561611717" title="water_mod" class="record-link ">water_mod</a>.log or
Hamsterball_water_mod.log) showing water plane discovery and per-frame
physics data.
🔗 Related Documents
water_mod
water_mod
Water visual effect mod
Files
hamsterball_water_mod.c— C source codebass.dll— Compiled DLL (PE32 i386)hamsterball-water-mod.zip— Packaged zip
Proxy Type
BASS.dll proxy. Installation:
- Rename original
bass.dll→bass_real.dllin the Hamsterball game folder - Copy the mod's
bass.dll(or renamed DLL) into the game folder - Launch Hamsterball
🔗 Related Documents
Windmill & Judge System (Tower
Windmill & Judge System (Tower + Expert Races)
Complete reverse-engineering analysis of the Windmill (Tower Race) and Hammy Judge (Expert Race) systems.
WINDMILL (Tower Race)
Overview
The Windmill is a CollisionLevel + static visual mesh. It has no game object vtable — instead, its collision mesh contains N:SWIRL events that rotate the ball on contact. The visual windmill mesh is rendered as part of the level's static geometry.
Binary Addresses
| Symbol | Address | Purpose |
|---|---|---|
| "Levels\Level4-Windmill" | 0x4D095C | Mesh file path string |
| "N:SWIRL" | 0x4CF928 | Collision event on windmill triangles |
| CollisionLevel_ctorWithLevel | 0x465080 | Creates collision level from mesh |
| Level_LoadMeshes | 0x465200 | Loads mesh vertex/index data |
| SceneObject_SetupCallback | 0x45DD60 | Registers collision with scene manager (stdcall, ret 8) |
| Stands_ctor | 0x462850 | Creates visual mesh object (thiscall, ret 4) |
| Rotator_AddBall | 0x43B6F0 | N:SWIRL handler — applies rotation to ball |
Creation Flow (Scene_LoadLevel4 @ 0x40D6D0)
// 1. Find "WINDMILL" mesh ref
mesh_ref = Level_FindObjectByName("WINDMILL");
if (mesh_ref == NULL) skip;
// 2. Get mesh from board+0x437C (pre-loaded by Tower ctor)
mesh_ptr = *(board + 0x437C); // Level4-Windmill
// 3. Create CollisionLevel
coll = operator_new(0x10D0);
CollisionLevel_ctorWithLevel(coll, mesh_ptr); // thiscall, ret 4
// 4. Load mesh data
Level_LoadMeshes(coll);
// 5. Copy position from mesh ref
coll->pos = mesh_ref->pos;
// 6. Register with scene manager for collision detection
SceneObject_SetupCallback(0x4F7360, 0x168, 0); // global scene + callback type
// 7. Store angle at board+0x438C
board->windmill_angle = (float)result;
// 8. Also creates a Trapdoor
trapdoor_mesh = Level_FindObjectByName("TRAPDOOR");
trapdoor = operator_new(0x10F8);
GlassStands_Ctor(trapdoor, mesh_ptr);
How N:SWIRL Works
The windmill mesh's collision triangles have N:SWIRL event names embedded in them. When the ball intersects these triangles:
// In collision dispatch (Tower Arena handler @ 0x414570):
if (stricmp(eventName, "N:SWIRL") == 0) {
Rotator_AddBall(board, ball); // 0x43B6F0
}
Rotator_AddBall registers the ball on a rotator AthenaList with a 10-frame tick counter. Each frame, a rotation matrix is applied to the ball's position and velocity, spinning it around the windmill center.
Tower Constructor Mesh Pre-loading
board+0x437C = MeshWorld_ctor("Levels\\Level4-Windmill"); // VA 0x4D095C
Why It Needs Both Collision + Visual
- CollisionLevel: Provides N:SWIRL collision events that spin the ball
- Visual mesh: The windmill mesh is normally part of the level's static geometry (spatial octree). When spawned globally, a separate Stands_ctor object is needed to render it.
JUDGE / HAMMY JUDGE (Expert Race)
Overview
The Hammy Judge is a standalone game object with its own vtable, update, and render functions. It appears as a hamster judge character that scores balls passing through gates. Fully spawnable as a standalone object.
Binary Addresses
| Function | Address | Calling Convention | Purpose |
|---|---|---|---|
| Judge_Ctor | 0x43A150 | thiscall(obj, board) ret 4 | Constructor — calls Level_ctor, sets vtable |
| Judge_Update | 0x434B60 | thiscall(obj) | vtable[11] — animation (trig oscillation) |
| Judge_Render | 0x43A270 | thiscall(obj) | vtable[18] — render judge mesh |
| Judge_scalar_dtor | 0x43A250 | thiscall(obj) | vtable[0] — destructor |
| Level_ctor | 0x461740 | thiscall(obj, D3D) | Base class constructor (loads mesh internally) |
Object Structure (0x1100 bytes = 4352)
| Offset | Size | Type | Description |
|---|---|---|---|
| +0x0000 | 4 | ptr | Vtable (0x4D52B8) |
| +0x10D0 | 4 | ptr | Board pointer |
| +0x10D4 | 4 | float | Position X |
| +0x10D8 | 4 | float | Position Y |
| +0x10DC | 4 | float | Position Z |
| +0x10E4 | 4 | float | Animation parameter |
Vtable (0x4D52B8)
| Index | Offset | Address | Function |
|---|---|---|---|
| 0 | 0x00 | 0x43A250 | scalar_dtor |
| 11 | 0x2C | 0x434B60 | Judge_Update (trig oscillation animation) |
| 18 | 0x48 | 0x43A270 | Judge_Render |
Creation Flow
Factory (inside CreateSawblade @ 0x40E590):
// 1. Find "JUDGE" mesh ref
mesh_ref = Level_FindObjectByName("JUDGE", 5, board);
if (mesh_ref != NULL) skip; // Already exists in Race mode
// 2. Allocate
obj = operator_new(0x1100); // 4352 bytes
// 3. Copy position from mesh ref to stack
push_pos(mesh_ref->pos);
// 4. Construct
// Judge_Ctor(this=obj, board) — thiscall, ret 4
// Inside: Level_ctor(obj, D3D_device) → loads "meshes\hammyjudge" mesh
// Sets vtable = 0x4D52B8
push board;
mov ecx, obj;
call Judge_Ctor;
// 5. Register
AthenaList_Append(board+0x4BBC, obj); // judge list
Constructor (Judge_Ctor @ 0x43A150):
Judge_Ctor(this, board) {
App = board+0x878;
D3D = App+0x174;
Level_ctor(this, D3D); // loads "meshes\hammyjudge" mesh
vtable = 0x4D52B8;
// copy position from param
}
Expert Constructor Mesh Loading
LevelBoard_Expert_ctor loads 3 copies of the HammyJudge mesh:
board+0x4BB0 = MeshWorld_ctor("meshes\\hammyjudge"); // judge 1
board+0x4BB4 = MeshWorld_ctor("meshes\\hammyjudge"); // judge 2
board+0x4BB8 = MeshWorld_ctor("meshes\\hammyjudge"); // judge 3
Update Function (vtable[11] @ 0x434B60)
void Judge_Update(this) {
FLD [this+0x10D8] // pos X
FLD [this+0x10DC] // pos Y
CALL 0x459860 // trig function (same as Pendulum — sin/cos oscillation)
CALL 0x4BA754 // some function
FLD [this+0x10E4] // animation parameter
// ... animation logic
}
The Judge animates using the same trig oscillation pattern as the Pendulum/Mace — it bobs/rotates around its position using sin/cos functions.
Collision Events
| Event | Handler | Action |
|---|---|---|
E:ALERTJUDGES |
ExpertCollisionEvents (0x40E6A0) | Iterates board+0x4BBC, activates all judges |
E:BELL |
ExpertCollisionEvents | Rings bell sound |
E:SCORE |
ExpertCollisionEvents | Awards points |
Judge List
Judges are registered to board+0x4BBC (dedicated judge list). The E:ALERTJUDGES event iterates this list to activate/deactivate judges.
Texture Files
| File | Purpose |
|---|---|
textures\hammy1.png |
Judge texture 1 |
textures\hammy2.png |
Judge texture 2 |
textures\hammy3.png |
Judge texture 3 |
Global Spawn Approach
Judge
Fully spawnable — standalone game object with vtable:
operator_new(0x1100)Judge_Ctor(obj, board)— loads "meshes\hammyjudge" internally via Level_ctor- Set position at
obj+0x10D4/+0x10D8/+0x10DC AthenaList_Append(board+0x4BBC, obj)— judge listAthenaList_Append(board+0x2578, obj)— general list- Per-frame: call
vtable[11](Judge_Update) for animation
No JIT mesh injection needed — Level_ctor loads the mesh internally.
Windmill
Requires spawning BOTH collision + visual mesh:
- Load
Levels\Level4-Windmillmesh viaMeshWorld_ctor - Create visual:
operator_new(0x10D0)→Stands_ctor(obj, mesh)→ visible mesh - Set position, register to
board+0x2578 - Create collision:
operator_new(0x10D0)→CollisionLevel_ctorWithLevel(coll, mesh) Level_LoadMeshes(coll)— load collision data- Set position on collision
SceneObject_SetupCallback(0x4F7360, 0x168, 0)— register collision- N:SWIRL events fire automatically when ball touches collision mesh
🔗 Related Documents
XML Data Formats Specification
XML Data Formats Specification
RaceData.xml — Race Configuration
Full game race parameters, loaded at startup.
Format
<RACENAME>
<TIME>seconds</TIME> <!-- Time pool for tournament -->
<PAR>seconds</PAR> <!-- Par time (target completion) -->
<WEASEL>time</WEASEL> <!-- Golden weasel (best) medal time -->
<GOLD>time</GOLD> <!-- Gold medal time -->
<SILVER>time</SILVER> <!-- Silver medal time -->
<BRONZE>time</BRONZE> <!-- Bronze medal time -->
<CAM>float</CAM> <!-- Camera profile parameter -->
</RACENAME>
Complete Race Data (from game files)
| Race | Time Pool | Par | Weasel | Gold | Silver | Bronze | CAM |
|---|---|---|---|---|---|---|---|
| BEGINNERRACE | 60 | 47.0 | 6.6 | 15.0 | 10.3 | 7.6 | 2.57 |
| CASCADERACE | 50 | 25.0 | 15.9 | 17.5 | 24.5 | 30.3 | 0.64 |
| INTERMEDIATERACE | 45 | 35.0 | 23.0 | 26.5 | 35.2 | 46.7 | 0.0 |
| DIZZYRACE | 40 | 35.0 | 37.2 | 41.4 | 48.0 | 58.8 | 0.88 |
| TOWERRACE | 35 | 35.0 | 36.5 | 40.0 | 47.8 | 59.2 | 0.03 |
| UPRACE | 30 | 25.0 | 29.7 | 32.0 | 35.1 | 40.8 | 0.33 |
| NEONRACE | 30 | 25.0 | 37.7 | 46.0 | 55.3 | 65.8 | 0.0 |
| EXPERTRACE | 30 | 20 | 34.0 | 39.5 | 48.0 | 61.2 | 0.0 |
| ODDRACE | 30 | 20 | 44.6 | 48.0 | 61.8 | 80.7 | 1.28 |
| TOOBRACE | 25 | 20 | 42.3 | 45.2 | 53.5 | 60.6 | 0.13 |
| WOBBLYRACE | 25 | 20 | 37.0 | 44.0 | 52.1 | 63.8 | 0.0 |
| GLASSRACE | 25 | 10 | 36.0 | 43.5 | 52.1 | 65.0 | 0.71 |
| SKYRACE | 25 | 5 | 40 | 46.0 | 53.5 | 60.0 | 0.44 |
| MASTERRACE | 55 | 2 | 65 | 73.4 | 88.8 | 112.4 | 0.0 |
| IMPOSSIBLERACE | 50 | 2 | 44 | 60.0 | 80.3 | 100.4 | 0.5 |
Notes
- TIME is tournament time pool (decreasing), not race duration limit
- PAR is the expected completion time; used for tournament scoring
- Medal times: WEASEL < GOLD < SILVER < BRONZE (lower is better)
- CAM value selects camera profile: 0.0 = default, >0 = alternate
- No warm-up race in RaceData.xml (race indices 1-2 aren't competitive)
- Silver in BEGINNERRACE (10.3) < Gold (15.0) — data file has SILVER/GOLD swapped for that race
- Internal vs display names: XML tags do NOT match display names.
BEGINNERRACE= Warm-up Race (Level 1),CASCADERACE= Beginner Race (Level 2).
The game's internal naming is offset by one from the display names.
Jukebox.xml — Music Track Mapping
Maps track names to MO3 pattern indices.
Format
<SONG> * comment *
<NAME>display name</NAME>
<HEX>pattern_index</HEX>
</SONG>
Complete Track List
| Context | Name | HEX (Pattern) |
|---|---|---|
| Title Screen | Main Theme | 0x02 |
| Title Screen (no intro) | Main Theme - No Intro | 0x03 |
| Beginner Race | Hamster Nation | 0x50 |
| Beginner Race (no intro) | Hamster Nation - No Intro | 0x50 |
| Cascade Race | Cascade Race | 0x7F |
| Cascade Race (no intro) | Cascade Race - No Intro | 0x81 |
| Intermediate Race | Gerbil Groove | 0x26 |
| Intermediate Race (no intro) | Gerbil Groove - No Intro | 0x28 |
| Dizzy Race | Dizzy! | 0x47 |
| Dizzy Race (no intro) | Dizzy! - No Intro | 0x47 |
| Tower Race | Happy Rush | 0x2F |
| Tower Race (no intro) | Happy Rush - No Intro | 0x2F |
| Up Race | Up Race | 0x8B |
| Up Race (no intro) | Up Race - No Intro | 0x8B |
| Neon Race | Neon Theme | 0xA8 |
| Expert Race | Fight! | 0x08 |
| Expert Race (no intro) | Fight! - No Intro | 0x09 |
| odd race | Ninja Hamster | 0x38 |
| odd race (no intro) | Ninja Hamster - No Intro | 0x3A |
| Toob Race | Rodenthood | 0x55 |
| Toob Race (no intro) | Rodenthood - No Intro | 0x56 |
| Wobbly Race | Hamster Chase | 0x19 |
| Wobbly Race (no intro) | Hamster Chase - No Intro | 0x1B |
| Sky Race | Bucky Break | 0x5C |
| Glass Race | Glass Theme | 0x9F |
| Sky Race (no intro) | Bucky Break - No Intro | 0x5C |
| Master Race | Master Theme | 0x71 |
| Master Race (no intro) | Master Theme - No Intro | 0x71 |
| Impossible Race | Impossible Theme | 0x94 |
| Game Over | Game Over | 0x62 |
| Tournament Overview | Tournament | 0x63 |
| Goal Reached | Goal! | 0x6B |
| High Scores | High Scores | 0x13 |
| Loading | Loading | 0x6F |
Notes
- HEX is a BASS_MusicPlayEx position/pattern index into Music.mo3
- "No Intro" variants start from a later pattern in the same song, skipping the intro section
- Some songs share the same HEX for both variants (Hamster Nation 0x50, Dizzy! 0x47, Happy Rush 0x2F, Up Race 0x8B) — these probably have the intro as part of the same pattern or skip it differently
- The MO3 file contains ALL music in a single module — different HEX values select different pattern/sub-song positions
Font Description Format
Binary font metric file at Fonts/<name>/font.description, paired with texture atlas PNGs.
Structure
[4 bytes] version_or_count = 2
[4 bytes] glyph_height = 11 (for ShowcardGothic72)
[4 bytes] char_height = 48 (pixel height)
Then per-glyph entries (mixed int/float, approximately 20 bytes each):
- Character code
- X position in atlas (float)
- Character width (float)
- UV coordinates for texture mapping
- Atlas page index (Data0.png vs Data1.png)
Font Atlas
Each font has texture atlas PNG files:
Data0.png— Primary glyph atlasData1.png— Secondary atlas (for larger fonts needing more glyphs)
Known Fonts
| Directory | Height (px) | Atlases | Usage |
|---|---|---|---|
| ShowcardGothic72 | 72 | Data0.png | Timer display (large numbers) |
| ShowcardGothic28 | 28 | Data0.png | Title screens |
| ShowcardGothic16 | 16 | Data0.png | HUD text |
| ShowcardGothic14 | 14 | Data0.png | Medium dialogue |
| ArialNarrow12Bold | 12 | Data0.png | Small labels |
Rendering
// Font render from decompiled code
Graphics_DrawText(font, text, x, y):
For each character in text:
Lookup glyph in font.description → UV coords + width
Draw textured quad from atlas at (x, y)
x += glyph.width
Reimplementation Notes (SDL2)
RaceData
- Same XML format, load with tinyxml2 or pugixml
- Medal times determine rank display after race
- CAM value maps to camera profile selection
Jukebox
- MO3 format: use BASS library with libbass, or convert to individual .ogg/.wav tracks
- For SDL_mixer: extract each pattern as separate .ogg during asset conversion
- Pattern index → track file mapping table
Fonts
- Replace with TTF fonts (Showcard Gothic is a standard Windows font, freely available)
- SDL2:
TTF_RenderText_Blended()for anti-aliased text - For pixel-perfect match: render Showcard Gothic TTF at same sizes
🔗 Related Documents
Help
Click here to access Cosma's documentation
Shortcuts
| Space | Re-run the force-layout algorithm |
| S | Move the cursor to Search |
| Alt + click | (on a record type) Deselect other types |
| R | Reset zoom |
| Alt + R | Reset the display |
| C | Zoom in on the selected node |
| F | Switch to Focus mode |
| Escape | Close the active record |
Hamsterball RE — Knowledge Map
Version 2.6.1 • License GPL-3.0-or-later
- Arthur Perret
- Guillaume Brioudes
- Olivier Le Deuff
- Clément Borel
- ANR research programme HyperOtlet
- D3 v4.13.0
- Mike Bostock (BSD 3-Clause)
- Nunjucks v3.2.3
- James Long (BSD 2-Clause)
- Js-yaml v4.1.0
- Vitaly Puzrin (MIT License)
- Markdown-it v12.3.0
- Vitaly Puzrin, Alex Kocharin (MIT License)
- Citeproc v2.4.62
- Frank Bennett (CPAL, AGPL)
- Fuse-js v6.4.6
- Kiro Risk (Apache License 2.0)

















