Files
openroller/docs/re_game471_notes.md
T

26 KiB

game471.exe: Notes (Static Analysis)

Goal: understand file formats and runtime behavior for a clean-room reimplementation.

This is based on static inspection (strings + disassembly). No patching, no runtime hooking.

Quick Map

  • Stage file path templates referenced in code:

    • data/stage/%s.dat
    • data/stage/%s_ext.dat
    • data/stage/%s_clip.dat
  • The game uses a background file reader (a small work queue + thread) that:

    1. Opens a file path stored in a request object.
    2. Reads the file into a buffer in 64 KiB chunks.
    3. Calls a callback function provided when the queue was created.

Background File Reader

Worker thread proc

  • 0x004ca320 is used as the thread entry function (passed as a function pointer).
  • It receives a small argument struct, extracts two pointers from it, then calls 0x004ca070.

File read loop

  • 0x004ca070 implements:
    • open file (CreateFileA), then ReadFile in a loop
    • destination buffer is request->buf at offset +0x204
    • expected size is request->size at offset +0x208

At several points it calls a callback:

  • queue->callback_fn stored at offset queue + 0x120
  • queue->callback_ctx stored at offset queue + 0x124 (passed as last argument)

Queue initialization

  • 0x004ca5f0 looks like the queue constructor/initializer.
  • It stores its arguments into this+0x120 and this+0x124.

So, to find the actual parsing logic for a specific file type, you typically follow the callback function pointer passed into the queue creation.

Stage Loader Entry

There is code that formats the stage file path strings and attempts to open:

  • data/stage/<name>.dat
  • data/stage/<name>_ext.dat
  • data/stage/<name>_clip.dat

This is a good anchor when looking for where the game requests reads for stage containers.

Static addresses in the 4.71 executable:

  • 0x0063ea70: formats all three stage paths and requests each resource.
  • 0x00427730: resource request wrapper.
  • 0x004279d0: creates the 0x4c-byte resource object (Ghidra mislabels it as an MFC CreateObject).
  • 0x0063ecc0: completion/cleanup callback which unwraps three resources before passing the result onward.

Main stage header

The file is big-endian. The initial words are not all section offsets. Confirmed section slots are:

Index Meaning
0 stage config
1 track draw distances
2 track points
3 notes
4 camera
5 TuneBGEffectData / FlowItem keys
6 visualizer
7 background texture names and image keys
8 first color table
9 objects
10 scalar/unknown; often 0x30, not an offset
11 points into the post-object extension offset table
12 scalar/unknown; can accidentally look like an in-file offset

This matters because treating every plausible header word as an offset can split the notes or camera section at a false boundary.

The post-object extension base is the reader position after the variable object stream. Relative offset slot 1 extends background colors and slot 2 stores object parents. Deriving that base from consumed bytes, as Android LoadBGData does, also handles the small mobile chart revision correctly.

Note array: confirmed wire layout

The note section starts at header word 3 and ends exactly at header word 4. Its layout is:

u32be name_count    // observed: 0 in all 2,968 dumped main charts
repeat name_count times:
    string8 name    // u8 length followed by bytes
u32be count
repeat count times:
    u32be time_ms
    u8    raw_type
    u8    type_override
    s16be params16[9]
    u8    flag24
    f32be params25[3]
    u8    flag37
    u8    flag38
    f32be params39[4]
    u32be params55[3]
    f32be param67
    u32be param71
    f32be params75[5]
    u32be param95

Thus each note record is exactly 99 bytes and the invariant is:

camera_offset - notes_offset == 8 + encoded_names_size + count * 99

The invariant holds on tested charts of very different sizes. For example, ac_10pt8tion_hard.dat has 669 records from 6162 through 125568 ms. Its raw-type histogram is 01:366, 02:231, 03:1, 04:3, 05:3, 09:63, 0f:2.

Observed raw types over the parsed corpus are 00, 01, 02, 03, 04, 05, 06, 09, 0a, 0f. The payload field meanings remain unnamed until backed by code references or controlled runtime observations.

The exact read order above comes from the game's stage constructor 0x005ed4c0 and its note-section parser 0x005ea800. The latter allocates a 0x11c-byte runtime object for every compact 99-byte wire record. It also proves one behavior: if type_override is non-zero, the runtime replaces raw_type with type 1.

The post-load pass at 0x005ebaa0 additionally proves that runtime types 11, 12, 13 and 14 are compatibility aliases: 11 becomes 10, 12/14 become 9, and 13 becomes 4. Other game modes can deliberately simplify types (for example 2 to 1, and 4/5/10/15 to 3), so a clean player should preserve both the raw wire type and the effective runtime type.

Full-dump validation with tools/scan_gc_notes.py GC/data/stage covers 2,968 main stage files and 1,080,259 notes with zero size mismatches. All of those files currently have zero note-name strings. The aggregate raw-type counts are:

00:6       01:719892  02:163462  03:67753   04:8651
05:5115    06:193     09:102699  0a:6360    0f:6128

type_override is non-zero in 52,506 records, so it cannot be discarded.

Confirmed type names

The executable contains a direct pointer table at 0x0077d480; its array index is the numeric note type. This yields the complete original enum:

Value Original name
0 NONE
1 NORMAL
2 FLICK
3 HOLD
4 SCRATCH
5 BEAT
6 MERRY GO ROUND
7 HIDDEN
8 HIDDEN2
9 CRITICAL
10 SLIDE HOLD
11 SLIDE COUNTER
12 TURN
13 SPIN
14 FINISH
15 DUAL HOLD

The same 16-entry sequence is repeated in several embedded reflection/debug tables, which independently confirms the ordering.

Confirmed runtime classifications

With the exact names assigned, several behavioral groups are also explicit in code:

  • 0x005e9480 classifies types 3, 4, 5, 10 and 15 as duration/path notes.
  • 0x005ebaa0 generates sampled paths for those types. Type 4 additionally builds two derived point arrays; types 3/10/15 share one path branch.
  • 0x005e94c0 singles out type 6. During gameplay setup it expands that note into several runtime judge objects using the runtime field originating from wire offset +71.
  • Types 1, 2 and 9 follow the non-duration branches in the preview/gameplay render path at 0x006492f0.

The enum names are now exact. The remaining reverse target is the meaning of the compact payload fields and the type-specific hold/slide behavior.

Appearance, duration and control-helper effects

The post-load pass at 0x005ebaa0 converts several compact fields using the BPM active at the note timestamp. With beat_ms = 60000 / bpm:

Wire field Runtime meaning
+6 signed marker-effect/UV selector; the note-head draw passes value - 1 to effect 3
+39 appearance lead in beats; runtime +0xbc = max(time - value * beat_ms, 0)
+37 enable positional fly-in
+38 draw the additive fly-in trail
+43 fly-in interpolation start, in beats after appearance
+47 fly-in interpolation end, in beats before the hit time
+51 duration in beats for types 3/4/5/10/15; runtime +0x58 and +0xac = time + value * beat_ms
+55 primary packed authored RRGGBBAA target colour; also used by duration geometry
+59 secondary packed RRGGBBAA colour
+63 integer fly-in oscillation count
+67 fly-in oscillation endpoint/phase scalar
+71 number of generated MERRY GO ROUND targets
+75 MERRY GO ROUND spacing in beats

0x005efe40 confirms that MERRY's span is count * spacing * beat_ms. Gameplay setup allocates three visual handles per generated target, which explains why treating type 6 as one marker loses most of the authored pattern.

The four blocks following the BPM table in StageConfig are variable-length timing lists, not four fixed records. Each list is u16be count, followed by count entries of u32be time_ms, u32be mode, f32be value. TuneTimingData::GetTime selects the last entry at or before the note: mode 1 returns value as milliseconds, mode 3 returns the spacing to the next note, and the other modes return value * beat_ms. The third list supplies the late boundary used as runtime +0xac for ordinary targets. FLICK (and raw type 0x10) receives an additional literal 0.2 * beat_ms.

Marker opacity is independent of the fixed judgment-window overrides below. 0x0063ff90 stores beat_ms at runtime +0x20; 0x0064ab80 divides it by the literal 2.0, while 0x005ebaa0 sets runtime +0xc4 to +0xac + 4 * beat_ms. With start = +0xbc, finish = +0xc4, and fade = beat_ms / 2, the exact unclamped shape is:

time < start + fade       : alpha = (time - start) / fade
time <= finish - fade     : alpha = 1
otherwise                 : alpha = (finish - time) / fade

The renderer clamps this to [0, 1]. Duration targets use their authored end as +0xac; MERRY GO ROUND first adds its generated-target span. The Linux player now carries the per-note BPM and +0xc4 equivalent instead of using the former guessed 236 ms marker lifetime.

The lower-right control helpers are not note-head effects. 0x00661680 reads the 16-entry table at 0x006ea040, loads the indicated efcdata.dat record, and stores it in runtime slot 0xb1 + type:

type:       0  1   2   3   4   5   6  7   8   9  10 11 12 13 14  15
effect id: -1 61  66  62  63  64  61 -1  67  65  69 -1 -1 -1 -1  68

Thus NONE and HIDDEN have no helper resource. FLICK and SLIDE HOLD also draw runtime slot 0xb9 as a direction overlay in 0x006492f0. The leading words of effect records 61--69 give animation lengths of 16, 38, 34, 13, 16, 14, 14, 38 and 38 update ticks respectively.

The common-effect interpreter is now mapped far enough to reproduce those records exactly. 0x005f2800 dispatches the child nodes, while 0x005f2030, 0x005f2250 and 0x005f23f0 implement looping, key lookup and step/linear interpolation. Every child has five relative track offsets. A track begins with u16be key_count, loop_start, loop_end, followed by keys of u16be time, u8 interpolation, f32be values[]. For a sprite child (type 2), the tracks are:

Track Values
0 UV frame plus six unused/secondary values
1 XYZ position
2 alpha, red, green, blue
3 XY scale
4 rotation in degrees

Type 0 is a parent rotation node in radians. A sprite whose child flag at +4 is set inherits that rotation, including the rotated XY offset. Effects 61--69 use UV record 13 from uvdata.dat, whose image is img13.bin: target rings, balls, hand prompts, arrows, action bursts and the type-specific help caption are separate animated layers. 0x006492f0 advances the records with an integer 60 Hz tick, places the selected composite at the literal virtual- screen coordinate (580, 788, 0) and applies the literal global XY scale 1.3. 0x006e9ee4 supplies a 1000 ms look-ahead, so only the current helper is selected; the effect is not cloned onto every world-space note. The Linux player implements this format in vectorail-gc/src/GcTargetEffect.cpp; it does not substitute a single guessed icon for the composite any more.

Cross-version confirmation of note-head rendering

The Android ARM64 build preserves C++ symbols which are stripped from the arcade executable. Its GameScene::DrawGameStageCharacter establishes the same ordering seen in game471: it disables depth writes, calls DrawWay, then calls DrawMark without disabling the depth test. The route therefore cannot punch holes through marker sprites drawn afterwards.

TuneGameData::BuildTimingDataSub (0x00196a74) fixes the note head's world position before gameplay by calling GetWayPosition(note_time) directly and storing the returned XYZ at runtime +0xe0. Long-note endpoints likewise use GetWayPosition(end_time) at +0xf8. There is no conversion through physical track distance and no note-time lead added in this path. DrawMark consumes these stored positions. The separate one-frame -16.6667 ms lookup in GameScene::SetCommonParam is used for the moving player segment, not for note placement.

The Linux player now performs the same direct timestamp lookup for heads, endpoint heads, generated BEAT/MERRY targets, and approach circles. Its shared physical-distance LUT is still needed for duration-body sampling; it now samples each authored segment independently and includes every exact corner. The previous cumulative t += 0.02 loop could jump across a segment boundary, measure a shortcut chord through the corner, and progressively place later distance-based geometry ahead of the authored route position.

GameScene::DrawMark creates each head with effect manager group 1, effect 3, and the final selector wire_mark_effect_id - 1. Group 1 is bound by GameScene::LoadSkinData to the selected skin's uv.dat and its texture list. The first signed word at stage-note wire offset +6 contains the selector source. Examples from ac_10pt8tion_hard.dat are:

Note Wire +6 Resolved UV record
NORMAL 1 1
HOLD 9 9
FLICK 10 10
SCRATCH 11 11
BEAT 12 12
CRITICAL 32 32
DUAL HOLD 37 37

Effect 3 has a 15-tick loop. Records 1--12 use 32x32 cells from the common skin atlas, while records 32 and 37 use 64x64 cells from the second common atlas. The effect instance uses the literal XY scale 0.025; consequently the authored 32px and 64px cells occupy 0.8 and 1.6 route-world units.

Directional heads have a second world-space layer. DrawMark plays effect 39 over FLICK with UV base 0 and over SLIDE HOLD with UV base 10. The final root angle is not wire +29 used directly: wire +25 is a vector length and +29/+33 are HPB angles. BuildTimingDataSub converts these through RotateHPB::ToVector_Deg; GameScene::buildGameData projects the route point and the displaced point with the camera evaluated at the note timestamp, then uses RotateHPB::SetVector (atan2(screen_dx, screen_dy_down)) and stores the resulting screen angle. It is computed before gameplay and remains fixed while the live camera moves. Effect 39 resolves to a 128x32 animated strip whose arrows extend to the sides of the 32x32 head; it uses the same 0.025 scale colour and marker alpha. This is independent of the lower-right control helper, even though that helper also has a direction overlay.

The marker animation clock is beat-synchronized, not a free-running 60 Hz counter. Android GameScene::SetCommonParam computes phase = 1 - (((time - bpm_change_time) / beat_ms * 2000) % 2000) / 2000 and DrawMark writes phase * EffectData::GetFrameMax() to effect field +8. Thus effect 3 runs backwards from frame 15 toward frame 0 once per beat and resets at every beat; BPM changes also reset the phase origin. BEAT adds sample_index * 2 to this base frame.

The approach ring is geometry, not another atlas layer. GameScene::drawHitTimingCircle draws a white 16-segment circle with additive blending, radius = 0.2 + progress * 0.5, and alpha marker_alpha * 128/255. For ordinary notes progress = (late_boundary - time) / (2 * (late_boundary - early_boundary)), capped at 1 and only shown before the target time. The arcade MERRY branch instead evaluates the analogous progress separately at each generated target's shifted timestamp.

Both that circle and the effect-3 head are camera-facing billboards. The mobile renderer calls SetTransposeMatrix(world_position, camera_matrix) for the circle and leaves the effect sprite in billboard mode; neither is laid in the route tangent plane. Duration bodies remain authored 3D route geometry.

The Linux player now decodes this selector, interprets effect 3, loads skin/common/common.png, skin/common/common2.png, the selected skinN/img.dat list and skinN/uv.dat, and renders the selected animated UV record. The former guessed effect/game/img4.bin glyph mapping and synthetic pulsing rings are no longer used for decoded GC stages.

Duration bodies are also type-specific in the executable:

  • Android HOLD does not submit the prebuilt ribbon array. Its normal gameplay branch calls DrawWay(max(current_ms, appear_ms), end_ms, color, color) with additive blending and a beat-reactive width from 3 to 5 pixels. This exact moving timestamp boundary is why dropping whole prebuilt samples produces a visibly stepped disappearance. Arcade still constructs HOLD helper geometry, but it is not evidence for the mobile draw path.
  • SLIDE HOLD calls its prebuilt triangle-ribbon path.
  • SCRATCH is sampled every 0.15 world units. 0x005ebaa0 derives two opposing paths with radius 0.2 and rotates the offset by 45 degrees per sample; 0x00641d50 emits six vertices per segment for each path.
  • BEAT is sampled every 0.55 world units and 0x0064ab80 creates a separate effect at every point. There is no continuous BEAT body. Its case-5 loop draws only points whose sampled timestamp has not passed, and writes base_frame + sample_index * 2 into effect field +8. Android's symbolized EffectData access confirms this field is the animation frame; the authored UV-list selector remains unchanged.
  • MERRY GO ROUND computes the +0xbc…+0xc4 alpha once outside its generated- target loop and multiplies every copied effect by that same parent alpha. Individual offsets from 0x005efe40 change target time and position, not the fade interval.
  • DUAL HOLD uses 0x00646d70 to build two ribbon bands. Its literal inner and outer offsets are 0.2 and 0.5.
  • Android GameScene::drawLongShotEffect (matching arcade 0x0064ab80) draws an effect-3 marker at the authored endpoint as well as the starting marker. The fixed endpoint UV bases are 29 for HOLD, 30 for SCRATCH, 41 for SLIDE HOLD, and 42 for DUAL HOLD. SLIDE HOLD additionally layers effect 39 with UV base 10 at the endpoint and rotates its root by the precomputed screen direction. Both endpoint layers inherit the long note's colour, fade alpha, billboard transform, beat-synchronised animation and 0.025 sprite scale.

Positional fly-in

GameScene::DrawMark converts wire +25/+29/+33 into direction vector D and starts from the authored route position P. With:

start = max(0, appear_ms + field_43 * beat_ms)
end   = max(0, note_ms   - field_47 * beat_ms)
u     = 0 before start, (now-start)/(end-start) in between, 1 at/after end

0x005ebaa0 first stores runtime +0xc0 = ROUND(appear_ms / frame_ms). 0x0064ab80 then computes the start from that stored integer, but computes the end from the still-fractional note_ms / frame_ms; each final boundary passes through another nearest-integer conversion at 0x0050a4a0. The live clock is quantized the same way. Flag +37 selects positional interpolation. With no oscillations the displayed position is P + D*(1-u). When integer cycles = +63 is positive, let c = +67 for values up to 1 and c = 2-(+67) otherwise; then position is P + D*(c - abs(sin(u*(asin(c) + cycles*2*pi)))). Integer cycles guarantee that the marker lands back on P at u=1. The approach circle follows the same moved position. Flag +38 draws an additive line from the initial external anchor to the moving marker before the hit time. The player preserves that nearest-frame 60 Hz quantization rather than smoothing the movement on the audio clock.

Tap judgment timing

The system-config loader at 0x00635c90 lays out the timing overrides in four per-difficulty arrays. The stage loader applies them in 0x005ed4c0, and the post-load pass at 0x005ebaa0 derives these runtime note boundaries:

Runtime field Boundary Easy override
+0x98 note time minus MissTimingOverride 236 ms
+0x9c note time minus UnmuteTimingOverride 202 ms
+0xa0 note time plus LimitTimingOverride 202 ms
+0xa4 note time plus MuteTimingOverride 0 ms

0x005d1690 is the tap-grade function. It selects +0x9c for an early tap or +0xa0 for a late tap and computes the absolute distance W from the note time to that boundary. It then builds thresholds using the literal table {100000, 100, 50, 25} at 0x006e8bc0, divided by 100. The grade loop checks indices 3 down to 1, which matches the adjacent original enum/string order MISS, GOOD, COOL, GREAT.

For an error magnitude e and the selected outer input distance W, the unclamped comparisons are therefore:

GREAT: e < 0.25 * W
COOL:  e < 0.50 * W
GOOD:  e < 1.00 * W

GreatMinTime is copied into the gameplay object at +0x104; if the computed GREAT threshold is below that value, it is clamped and COOL is redistributed by the same routine to great + (good - great) / 3, capped at GOOD. With the shipped Easy W = 202, the absolute-error thresholds are 50.5 ms for GREAT, 101 ms for COOL, and 202 ms for GOOD. HARD/EXTRA use W = 134, producing 33.5/67/134 ms. The shipped GreatMinTime = 32 therefore does not clamp these four standard difficulty windows, but the branch matters for custom configs.

MissMarkOverride=1 in the shipped data/system.cfg makes the stage loader replace the timing-list results with these four Simple/Normal/Hard/Extra arrays:

MissTimingOverride   = (236,202,168,168)
UnmuteTimingOverride = (202,168,134,134)
LimitTimingOverride  = (202,168,134,134)
MuteTimingOverride   = (0,0,0,0)

SHOT miss gating

The two stage WAVs remain sample-synchronized, but they are not mixed at a fixed level. FUN_00640790 reads each player's IsMute state after the judgment update. For player zero, FUN_00611920/FUN_00611940 pass 1.0 or 0.0 through FUN_00611800 to the third volume multiplier of channel 13 (SHOT); channel 12 (BGM) is untouched. The write is immediate rather than a timed SoundManager fade.

An unresolved target closes the SHOT gate at runtime +0xa4, which is note time plus MuteTimingOverride (zero in the shipped configuration). The note remains judgeable through +0xa0; a successful late judgment opens SHOT again immediately. This is why a miss removes the chart-performance layer without stopping or seeking either WAV.

Gameplay tap and AD-LIB sound effects

The hit sounds are a separate three-WAV sound set; they are not baked into either stage stem. FUN_00613430 resolves the selected entry from data/boot/se.dat and loads its three names from record fields +0x14, +0x18, and +0x1c. The default se0000 ("Ver.3 Set") maps them to:

Slot WAV SEList.csv volume Playback channels
0 SE_ARRANGE.wav 87 6, 7
1 TAP_SE1.wav 80 10, 11
2 TAP_SE2.wav 77 8, 9

Every slot is deliberately double-buffered. FUN_00611fc0 round-robins slot 0 over channels 6/7. FUN_00611e70 round-robins the two booster sounds over 10/11 and 8/9. Consequently consecutive taps overlap instead of cutting off the previous sample. Gameplay update FUN_00640790 consumes the two booster edge flags at player fields +0xed/+0xee and the separate AD-LIB flag at +0xaa; the booster edge is what starts TP1/TP2, before the resulting note rank is known. A mistimed press still produces its tap sound. The AD-LIB flag selects slot 0 independently.

The other se.dat entries preserve the same three-slot layout, for example ALB_06/TP1_06/TP2_06 for the Scratch set. The chosen set id comes from the player customization state (Afx state +0xce0 in the gameplay loader), not from the stage chart.

The Linux player currently loads the default Ver.3 set at the exact CSV gains and reproduces the original two voices per slot. It decodes serialized marker byte +5 into the runtime +8 AD-LIB flag and starts ALB after a successful rank on such a marker. D-pad inputs are treated as booster 1 and face-button inputs as booster 2; this also matches the PSP-style control grouping.

Ranked hit effects

setRankedEffect records the rank and gameplay timestamp. The later DrawGameStageCharacter pass selects group-1 effect rank + 0x1c, so the effect IDs are 29 MISS, 30 GOOD, 31 COOL, and 32 GREAT. Their decoded lifetimes are respectively 20, 12, 12, and 12 frames. The effect advances from the hit timestamp at 60 Hz, uses additive blending, a global XY scale of 0.03, a camera-facing matrix, and a stored random root rotation from rand() % 360.

Hit-type dispatch and long-shot ranks

The arcade dispatcher at 0x005d5fb0 selects the handler from the effective marker type: 1 TAP, 2 FLICK, 3 HOLD, 4 SCRATCH, 5 BEAT, 6 MERRY, 9 CRITICAL, 10 SLIDE HOLD, and 15 DUAL HOLD. Android's symbolized GameScene::checkHitMark independently confirms the same mapping and names type 9 checkHitMarkDualTap.

Dual tap stores the timestamp of its first independent input, consumes a second independent input, then grades using the first timestamp. DUAL HOLD also waits for two independent inputs, but begins its measured hold duration when the second arrives; releasing either side ends it.

Arcade FUN_005d0d80 grades HOLD and DUAL HOLD by the percentage of the authored body actually covered after clamping press/release to its start/end:

GREAT: held > 80%
COOL:  held > 60%
GOOD:  held > 40%
MISS:  otherwise

The comparisons are strict. Before computing the percentage it also applies the original short-tail compensation: if 20% of the body is below 66.666664 ms, that difference is added to covered time. FUN_005d0eb0 finalizes a still-pressed hold at the authored end; an early release grades it immediately.

BEAT (FUN_005d41b0) uses the same long-shot percentage grader, but records the first tap and continually replaces the covered endpoint with each later tap. If no fresh trigger arrives for BeatEnableTime (200 ms in the shipped config), it finalizes at the last trigger. Thus it measures how far through the body the repeated taps continue, rather than merely counting taps.

SCRATCH (FUN_005d44f0) listens to four directional triggers. The first one starts coverage; each later trigger extends it only when its direction differs from the previous accepted direction. It finalizes after ScratchEnableTime (250 ms) without a direction change or at the body end, then uses the same 40/60/80-percent long-shot grader.