Idea for a 2D port: Oddworld Abe's Oddysee using R.E.L.I.V.E. engine?

I honestly wasn't trying to put you down. it's just really frustrating that the answer of "how many palettes can the Saturn do" is such a simple thing to actually find out, no need for guessing of burning tokens to get an answer that is completely wrong. And now you know for sure, because I explained it. Good luck with your project.
@nando:
Yes, don't get me wrong, I was mostly frustrated by myself other than something else.
I meant it when I said I needed a good night of sleep and a proper look on it. I should take the time to investigate and find the real problem and solution.
I had to leave for my flight and I knew I would have no more access to my Saturn for over a month, so I tried to finish up too many things at the same time, which only had very poor results because of the way I did it. I will do better next time (and more importantly, will verify what I write here if it was originated by an AI).
My bad on that, thanks again for caring enough to reply and correct my mistakes.
 
Last edited:
@nando:
Yes, don't get me wrong, I was mostly frustrated by myself other than something else.
I meant it when I said I needed a good night of sleep and a proper look on it. I should take the time to investigate and find the real problem and solution.
I had to leave for my flight and I knew I would have no more access to my Saturn for over a month, so I tried to finish up too many things at the same time, which only had very poor results because of the way I did it. I will do better next time (and more importantly, will verify what I write here if it was originated by an AI).
My bad on that, thanks again for caring enough to reply and correct my mistakes.
If you have questions that seem like they should have obvious answers, honestly just ask. There is discord too, and Saturn has halfway decent documentation. VDP2 is the most complicated (almost 500 pages) but if you know what the question is, you can probably find the answer there. The exception being that the descriptions are not always straight forward or easy to understand 😀
 
If you have questions that seem like they should have obvious answers, honestly just ask. There is discord too, and Saturn has halfway decent documentation. VDP2 is the most complicated (almost 500 pages) but if you know what the question is, you can probably find the answer there. The exception being that the descriptions are not always straight forward or easy to understand 😀
Yes, I'm on the discord also, and have the official sega saturn developer cd iso mounted on my computer.
Honestly I have zero excuse not to look it up.
The problem was more about knowing what to look for in this situation, and the final rush to get my plane. But hey, lesson learned. Thanks.
It also seems that I was looking in the wrong direction the entire time, as there are plenty animation / textures poorly converted / stored in the current version, such as shadows for example. Clearly needs some tuning. Carlos point was still helpful and helped me sort out one of the issue.
I'll list, investigate and solve them one by one when I have the time to.
 
Hi guys, I've been wrapping my head around the CRAM issue and the stuttering, and I think that we could look at it from a mixed 4-BPP/8-BPP pipeline perspective combined with a very surgical use of the SCU-DMA for key triggers.
Abe and the main backgrounds definitely need 8-BPP, but maybe we can crunch all secondary assets down to 4-BPP. Stuff like shadows, portal effects, or explosions could easily live in 16 colors with some good old-school dithering, saving a massive amount of palette banks.
As for the SCU-DMA, instead of triggering transfers chaotically, we could restrict its scope to four precise scenarios:
1-SCU DMA Level 0 bursts used exclusively during flip-screen transitions, dumping the background from Work RAM to the VDP2 VRAM (inactive layer) right before Abe hits the screen boundary.
2-Handling Abe's heavy action states via sprite streaming into the VDP1 VRAM during V-blank, so we don't clog the memory with all his frames at once.
3-Mirroring the VDP1 command tables in Work RAM High and performing an indirect DMA transfer per frame to VRAM, keeping both SH-2 cores completely free for AI and collision checks.
4-And for the palette overlapping, updating color data directly from Work RAM to the CRAM palette banks using DMA synced with V-blank.
Sounds like a hot take and a lot of asset management on the PC side first, but maybe it's a viable way to bypass the hardware limits. What do you guys think?
 
I'll check that, thanks. The palette overlapping is fixed, and so are most animations (no more black and white corruption).
I still have to fix the moving barrel rectangle animations, and implement some shadows with saturn hardware (right now, they're 8bpp and using ram space for nothing, just dumbly converted from original format), then I'll see how to optimize everything, ease up ram usage, and adding up the audio.
 
I'll check that, thanks. The palette overlapping is fixed, and so are most animations (no more black and white corruption).
I still have to fix the moving barrel rectangle animations, and implement some shadows with saturn hardware (right now, they're 8bpp and using ram space for nothing, just dumbly converted from original format), then I'll see how to optimize everything, ease up ram usage, and adding up the audio.
Hi N0rt0N85,
That’s fantastic news! Hearing that you already fixed the palette overlapping and the black and white animation corruption is amazing. You are ironing out those bugs fast even while traveling! Handling the shadows natively through Saturn’s hardware instead of wasting RAM with raw 8bpp conversions is definitely the right call, it will free up so much headroom for the upcoming audio engine.
I'm working on a setup right now where I have my raw videos in uncompressed .avi, alongside the audios, backgrounds, and sprites already converted into Saturn-compliant formats for my first level. I would love to send these files over to you via Google Drive if they can help you with your asset testing and pipeline development! Seeing how you map out the RAM tiers is pure gold, as I'm facing those exact same file management challenges at the moment.
Please keep up the amazing work! Enjoy the rest of your trip in August, and we’ll be right here eagerly waiting to see how this engine behaves on real hardware at the end of the month. History is being fixed right here.
Cheers from Spain, you are an absolute crack!
 

What's new since July 20​

Colors finally behave (July 21–27)​

The Saturn has very little dedicated color memory, and for weeks characters and objects had been stealing each other's palettes: Mudokons flashing wrong colors, levers turning odd shades, elevator barrels going dark.
  • Every recurring character in the first level fits into a tiny shared color budget with zero quality loss, so Abe, the Mudokons and the Sligs now get permanently reserved color slots, their colors are correct and stable instead of fighting for space (the idea suggested by @carlos24_).
  • Objects like levers and props no longer inherit the colors of whatever was on screen before them.
  • Background animations (the elevator chain and platform) no longer flash black and white.
  • The elevator barrels got their real colors back: the game was guessing how many colors each image needed and guessing wrong, so the barrels shared a palette with the pull-rope and lost. Now the game measures what each image actually uses.

Cleaner screen transitions (July 29)​

When walking from one screen to the next, leftover foreground scenery from the previous screen used to hang on the screen until the new one finished loading. It's now wiped instantly the moment the transition starts.

Real shadows (July 29–31)​

Characters' shadows had been drawn as solid dark blobs, all the same size.
  • First pass: a checkerboard-dither trick made them roughly 50% see-through, but it looked ugly.
  • Final version: the shadows now use a native Saturn hardware feature (VDP2 shadow function) that genuinely darkens the background beneath them: smooth, truly translucent, and costing essentially nothing in performance. They also scale properly: a Slig's shadow matches a Slig, shadows shrink as you jump, and they clip correctly at ledge edges, just like on the PlayStation.

The sideways-Mudokon glitch (July 29)​

Mudokons facing back would visibly jitter a few pixels sideways off their shadow. Cause: when the Saturn mirrors a sprite, some invisible padding pixels got mirrored to the wrong side, pushing the character over. Flipped sprites are now anchored from the other edge, so characters sit exactly on their shadows in both directions.

️ Crash-proofing (July 31)​

Wesker's real console crashed after ~51 minutes of play. The cause was tracked down from a photo of the crash screen: the engine occasionally asks an animation for a frame that no longer exists in memory, and one unguarded path let that kill the whole machine. Two fixes:
  • Any bad animation frame now safely disappears for a single frame instead of crashing the console.
  • A memory bookkeeping bug (a "freed but not forgotten" buffer that could silently corrupt other data) was closed.

More characters on screen, no more tearing (Aug 1–2)​

The biggest fix of the period. Two long-standing complaints: Abe's sprite visibly tearing when animations change, and sprites vanishing on busy screens (like the lower elevator room), fell together after a deep audit of the Saturn's sprite library:
  • It turned out the system had been silently deleting any sprite past the 40th, and deleting the closest ones first, which is exactly Abe and the enemies. The limit is now 60, with the underlying memory laid out the way the hardware manual actually requires (the old layout was quietly corrupting itself past 43 sprites, the real cause of an older mystery crash).
  • The tearing happened because the game sometimes redraws a character's image twice in the same tick, and the second redraw landed on the exact image the hardware was still busy displaying. Fixed: confirmed gone by eye and by counters (178 tear events per session → 2).
  • Along the way, ~4 KB of scarce memory was reclaimed by measuring (rather than assuming) how much a legacy reserve actually needed: the answer was zero.
Still to fix: the elevator chain is not optimized and continues to glitch.

SOUND IS ON (Aug 3)​

The port is no longer silent: sound effects and music now actually play (confirmed in the emulator). This was the biggest missing piece of the whole project.
  • The Saturn's dedicated sound chip is now driven directly, with all 32 of its voices mapped to the game's audio engine, including the same voice-recycling logic the original game used, so busy scenes steal the least important sound, not a random one.
  • All the game's audio samples are converted offline into the Saturn's native format. The sound bank for the first level squeezes into the chip's tiny memory (99.7% full!) with a trick: every voice line is kept 100% untouched, while less critical effects are gently downsampled with a proper filter so they still sound clean.
  • The music clock now ticks, which means the game's adaptive soundtrack works. The music can react to what's happening on screen, like on PlayStation.
  • The pitch math was rewritten for the Saturn's CPU (which has no floating-point unit): note pitches are now computed with fast integer math accurate to less than 1/100th of a semitone.
  • Three independent reviews of the code caught and fixed a batch of subtle bugs before they could ever be heard: sounds getting cut short after pitch changes, a slow memory leak in the sound chip's RAM, a screeching tail when a voice was stolen, and a rare race condition at startup.

Again, thanks @slygamer, @Wesker for the hardware tests and @carlos24_ for ideas and emulator tests.

My attempts to create native proper lightings failed so far (green and orange lights on platforms and doors, for example), and still plenty of bugs to go, but I'm happy with the progress.
 
Last edited:
Here's the update video, thanks to @slygamer's hardware capture:


EDIT:
Disclaimer: As always, feel free to correct me, as it might me help understand better and maybe fix some bugs. I'm still exploring the hardware, I might miss something or say something plain wrong. The following no source of truth value, only my current understanding of things applied to the port.

Tethys Odysseus - Oddworld: Abe's Oddysee on Sega Saturn - update (4–10 August)
Note that the video covers a slightly wider window than this post. It goes back to
late July, so it also shows the audio and sprite-ceiling work from the last update.

Since August 3rd the port went from "the level runs" to "the level looks like the game".
Below is what changed, plus the two things I got wrong.

── DISPLAY: NATIVE 240 LINES ─────────────────────────────

The renderer had been running 320x224, with the vertical pre-scale applied
NEAREST-NEIGHBOUR at 14/15 — one scanline in fifteen was deleted, and which one
shifted as a sprite moved vertically. That is a shimmer in motion, not a uniform
softening. It is now 320x240: 1:1 vertically with the PSX original, no resampling at
all, horizontal still a clean integer 2:1 from the 640-wide PC art.

Eleven sites had to move atomically for that (the pack contract is mirrored in Python
and C++), and a twelfth escaped the platform layer entirely: the streaming CAM path
validates the Bits header independently, inside RELIVE, and still demanded 224 rows.
First build booted straight into a fatal. A dimension contract that wide needs a
literal sweep across BOTH repos.

── WHAT PAID FOR IT: THE VDP1 TAIL ───────────────────────

Back at S5, 217,088 bytes of CPU-only tables (the AO VAG table 147,456, the sound-entry table 36,864, the renderer's CLUT mirror 32,768) had been exiled into the tail of VDP1 VRAM, because LWRAM was fully committed to the resource heap. That was always a debt, and it was charged to the texture heap: 229,376 usable bytes out of a possible 450,560.

A LIFO tail-trim: SRL's texture allocator is apure bump allocator with no per-id free, so retired slots ARE the free list, and
42-61% of every byte ever claimed was sitting in them. Everything above the highest id held by a LIVE slot is retired by construction and comes back in one call. No search, no adjacency test. Measured peak went 281 -> ~185 KiB.

Consequence: 240 lines fit under the 256 KiB no-cart ceiling with 58 KiB spare. That killed the dual-pack / cart-tier / disc-fork plan outright. One pack, one image.

── CONVERTER: TWO FIDELITY WINS, ZERO RUNTIME COST ───────

1. Anim chunk compaction. The converter halves every cel for Saturn but was keeping each frame at its PSX offset and zero-filling the slack. Measured over the converted R1.LVL: 38.7% of ALL Anim payload bytes: 952,656 of them, ABEBSIC.BAN alone 93,512, was padding, and the ResourceManager pays for it twice (resident block + contiguous stage). Repacking end to end and rewriting every cross-reference took the R1 pack from 7.66 to 6.59 MB. Lossless: all 4202 frames of 219 chunks re-decode bit-identical.

2. Stopped decimating actor cels. The 2:1 horizontal pre-scale was nearest-neighbour ON PALETTE INDICES. Indices are indeed meaningless to filter. Colours are not.
Dropping every other column discarded a DIFFERENT colour on 37.77% of opaque texels (1,479,863 measured). It now averages the covered columns in COLOUR space and snaps the result back into the SAME palette. 0 bytes of VRAM, 0 fill, 0 runtime code, 0
display-mode risk. This is plausibly most of what read as "the Saturn version looks halved", and it was available all along behind a comment that was true about indices and wrong about pixels.

── THE LED MARQUEE FINALLY RENDERS ───────────────────────

Prior note: LCD text is still bugged and have poor performances.

LCDFONT.FNT was the one resource the converter skipped, so the LCD factories had been stubbed since S4 after a null AliveFont sent DrawString polys into BIOS ROM.

French and Spanish marquee text are extracted offline from the localized executable by signature search (the two LCD palettes that follow the table), not by hardcoded offset. The tutorial strings also name real Saturn buttons now: Input_GetButtonString had been a stub returning "" since P1, so 12 of the 37 messages read "To jump, press ."

One good bug from that work: the French disc went black at boot. Root cause was a single signed char. String_FormatString reads the marquee as plain `char`, this port forces -fsigned-char, so every CP437 accent arrives NEGATIVE, fails `>= ' '`, falls into
the button-glyph branch, and `in_char - 6` truncated back to char turns 0x82 into +124. That indexes a 53-entry table at 124, lands in unrelated .rodata, and strcpy runs unbounded into a 512-byte buffer inside the ~30 KB HWRAM pool. No fatal, no exception screen, a heap smash is not a CPU exception.

── COLOUR: THE TINT CHANNEL ──────────────────────────────

Two reported defects, one cause: characters were not being hidden by dark columns, and Mudokons were the same colour as Abe. AO darkens AND tints an actor by modulating its PRIMITIVE RGB, and our textured path read that RGB nowhere.

Abe and the Mudokons genuinely SHARE one CLUT in the game data, so the tint is the ONLY thing that tells them apart, dropping it made them identical by construction.

VDP1 gouraud cannot stand in here: in a colour-BANK mode it offsets the colour CODE, i.e. the palette INDEX, which on an arbitrary AO clut samples unrelated colours (this is the red errata on VDP1 manual p.93 in the Kronos-corrected scans). So the palette itself is modulated, which is effectively what the PSX hardware did. Sized from the level data rather than from a gauge: R1 has exactly ten ShadowZone TLVs over 8 cameras of 105, never more than two on one camera, which is what makes a four-entry cache enough, and why this costs NO new CRAM.

Same pass found that non-character 8bpp takes the ctor default 105 = 81% brightness, so the whole prop layer had been a fifth too bright since S5.

── LOADING: ~8 s -> ~2 s ON HARDWARE ─────────────────────

The CD reader was doing one GFS_Seek plus one blocking GFS_Fread PER SECTOR. 40 to 55 drive round-trips per screen change, purely because the bounce buffer was 2 KB of HWRAM. It moved to LWRAM. 16 sectors a round-trip now.

The second half is perceptual and is not a performance fix: the screen dims to black over 4 presents WITH THE SPRITE LIST STILL LIVE, holds through the load, and ramps back over 8 composed frames, so the game is already running and the actors come up with the background instead of after it. Before, you watched the new screen paint itself in from the top as sectors landed. The video has a side-by-side.

── AND ONE 19-MILLISECOND CALL ───────────────────────────

On the tick Abe chants, ONE Vram_alloc_block call cost 19.0 ms. 505,344 SH-2 cycles, 95% of the worst VUpdate and 73% of the whole update phase on a 67 ms tick. Not an accumulation: one query.

The stock allocator picks direction by AREA. h*w >= 1024 descends; everything smaller ASCENDS from y = 0 one row at a time. A 20x21 chant orb therefore walks the contested end of VRAM and calls Vram_Is_Area_Free 2*(256-h) = 470 times. The fix skips the rows that provably cannot be the answer (a row only improves on the one below by LOSING a blocker, and a blocker leaves at its bottom edge), keeping the stock predicate as sole authority on x and collision. Worst allocator tick 3948 -> 760 raw ticks, update phase 26 -> 18 ms, worst tick 67 -> 51 ms.

── TWO THINGS I GOT WRONG ────────────────────────────────

1. My previous allocator "optimisation" was a 72-84% REGRESSION on hardware. Five controlled comparisons at identical occupancy, no exceptions. The equivalence proof was correct and irrelevant: it showed the ANSWER is identical and the ITERATION COUNT drops, and never priced one iteration. The stock loop early-exits at the FIRST blocker; mine scanned all N every iteration, and N reaches 55 in the field. A complexity bound is not a measurement. Reverted.

2. I spent four builds hunting the in-play slowdown by naming suspects. Six mechanisms measured and refuted. Accumulation, sub-rect fill, the palette hash, VDP1 fill, the submission path, the SCSP key-on busy-wait. Every one of which I had priced by READING the code and then quoted as if measured. So I stopped naming suspects and split the clock instead. Result: pu 13.6 / pa 11.5 / pv 8.4 / pw 8.5 ms per tick against a 33 ms budget. Three unrelated phases, different authors, different data, all slow in the SAME proportion. That is not an algorithm, it is the signature of a GLOBAL factor, and ours was written in our own Makefile: the core is compiled -Os because code size was the binding constraint at P1, and I forgot to reopen it after the RAM cart moved the resource heap. That is the next piece of work.

── STILL BROKEN, NAMED ───────────────────────────────────

  • In-game frame rate. On hardware, 57-92% of ticks run late on heavy screens. The LCD boards are the first suspect: every screen carrying one runs measurably slower.
  • Door lamps are still the raw first-pass conversion. The 4bpp glow path can't go through a CRAM colour bank, because a bank only ever treats texel index 0 as transparent, while the lookup-table path can make ANY entry transparent, a cel elying on several transparent indices paints its bounding box.
  • The elevator chain still renders wrong (parts missing, no animation)
    Faint white lines appear during the chant.

── WITH THANKS ───────────────────────────────────────────

@slygamer: hardware captures and testing; everything in the last video is hers.
@Wesker: hardware testing. Several fatal-error reports from real hardware led straight to root causes I could not have found in emulation.
@carlos24_: emulation testing, the shared-palette idea behind the character colours, and this post that started the whole port.
 
Last edited:
Nice! This was actually the port I started looking into doing myself last year. Then I got massively side tracked trying to build a decoder for the videos because I wanted to see if the Saturn was able to run them without re-encoding lol PSX str format on saturn

I wish you luck it will be more fun playing the game if someone else does the port anyway haha. I still remember my brother not letting me play it on his playstation when we were kids just to be a jerk and I said I don't care because its coming to Saturn too (which was rumored at the time)... I'm still waiting to finally play it.
 
Nice! This was actually the port I started looking into doing myself last year. Then I got massively side tracked trying to build a decoder for the videos because I wanted to see if the Saturn was able to run them without re-encoding lol PSX str format on saturn

I wish you luck it will be more fun playing the game if someone else does the port anyway haha. I still remember my brother not letting me play it on his playstation when we were kids just to be a jerk and I said I don't care because its coming to Saturn too (which was rumored at the time)... I'm still waiting to finally play it.
Ahahah I totally get it!
Thanks for the post, I'll look at it. I didn't try anything with the videos yet, just pure theories for now. I'll try to implement it after the main game is debugged and optimized.
 
Disclaimer: As always, feel free to correct me, as it might me help understand better and maybe fix some bugs. I'm still exploring the hardware, I might miss something or say something plain wrong. The following is not source of truth value, only my current understanding of things applied to the port.

Tethys Odysseus - Oddworld: Abe's Oddysee on Sega Saturn - update (11-17 August)


── LIGHTS: THE VDP2 DOES THE BLENDING ────────────────────

A VDP1 sprite cannot blend against a VDP2 background. VDP1 half-transparency (colour calculation mode 3) works on "the pixel data of the original graphic and the pixel data read from the write coordinates" - the frame buffer, which holds VDP1's own output only - and the manual notes it takes six times longer than drawing with no colour calculation. It also wants RGB codes; our cels are paletted.

So VDP2 adds instead. CCCTL (1800ECH) bit 8, CCMD = 1, is "add as is (additive blending)": saturating addition of the sprite onto the CAM, which is PSX blend mode 1 (B + F) reproduced 1:1. slColorCalc(CC_ADD | CC_TOP). Register only, no bank, no fill.

The per-sprite gate is NOT the CC bits, which only pick a ratio slot. VDP2 tests the sprite's PRIORITY field against a colour calculation condition number under one of four conditions (SPCTL 1800E0H bits 13-12: PR<=CCN, PR=CCN, PR>=CCN, or colour-data-MSB). PR=CCN with the number 1; every ordinary sprite ships PR = 0, so only tagged sprites blend. Sprite type 3 (SGL's boot SPCTL 0x0023) is MSB-first SD:1 | PR:2 | CC:2 | DC:11, so PR=1 is bit 13, CC=1 is bit 11, and tagging is COLR |= 0x2800.

8bpp only, and that matters: CMDCOLR is colour bits only in a colour BANK mode. In CL16Look it is a VRAM address for the lookup table, in CL32KRGB it is literal RGB555 - OR-ing 0x2800 into either corrupts a palette pointer or the red channel. Hence five files promoted 4bpp -> 8bpp offline in the converter (SQBSMK, EXPLO2, SPLINE, FLNTGLOW, BLOOD), 11.6 KiB of a 434 KiB texture heap.

The "this is a light" discriminator is AO's own blend mode: eBlend_3 is authored by exactly six classes (DoorLight.cpp:111, DoorFlame:42, MotionDetector:363, ZapLine:46, Particle:55, MainMenu:4012), all light or energy effects. eBlend_1 takes the tag on a second predicate rather than by widening the first, which would have put a halo on every explosion shard.

One rule that cost a build: mesh is a SUBSTITUTE for blending, not a companion to it. Stippling prims that were already blending is what I saw as a black checkerboard in Abe's green gas - VDP1 skips meshed pixels entirely, so the CAM shows through. The gate is now the cel's ability to blend (depth != 8).

Three claims from last update are withdrawn: the fart cloud is SQBSMK.BAN and is gated on the fart-gas cheat, not ABEGAS.BAN; BLOOD.BAN does not blend on PSX either (its STP indices are 3..15, all pure white, used zero times); and "eBlend_0 + semiTrans" describes ~330 AnimIds in R1, so it identifies nothing.

── THE SPRITE CEILING: 60 -> 128 ─────────────────────────

The cap came from SGL_MAX_POLYGONS = 64, carried over from a sibling project's note ("MaxPolygons > ~64-80 starves the TLSF heap -> boot loop"). That was measured against a pool of ~80 KB. It is a trade, not a hardware limit - the work area comes 1:1 out of the TLSF pool - and the path reclaim below took the pool to 158 KB. So: SGL_MAX_POLYGONS 64 -> 192 (work area 0x36F8 of its 0x4000 reservation, 2,312 B spare), cap 60 -> 128, kZBase 80 -> 148.

The cap is measured every frame, not asserted, because crossing SGL's sort-table bound is not a lost sprite - it is command bytes written inside the sort table, i.e. a silent SH-2 reset.

SGL fills three of those terms in at slInitSystem, so no map read settles them. If 192 polygons pay for 128 sprites the answer is 128; if not, the build degrades instead of resetting. Rank-based shedding is now inert.

── THE ELEVATOR CHAIN ────────────────────────────────────

Drawing wrong since S5, four builds of gauges spent on the assumption that something was dropping it. The gauges finally said 30 rope rects offered, 30 drawn, nothing lost - so I read the artefacts instead. The shipped R1ROPES.BAN texture, re-decoded off the disc, is a solid 6x16 opaque block; the C07 background has no chain painted in it. The chain was on screen and wrong, not absent.

Cause: the far-plane decimation gate compared destination height against the slot's source height and never asked how many SOURCE rows the draw sampled. Its own comment names the discriminator it failed to use - AO shrinks the QUAD, not the UVs - but a vertically CLIPPED poly shrinks the quad and its UVs together. Rope::VRender clips its end segments, one clipped by 3+ rows trips the gate, and decimation rewrites the SHARED slot in place (all 14 segments point at one Animation, Rope.cpp:137). One clipped end segment halved the whole chain.

Then it was too large, then too thin, and both were my mistakes too: removing a max(8) content floor stopped column DUPLICATION but exposed point sampling, which cannot represent a feature narrower than its own step. The rope is 3 opaque columns of a 4-wide cel. The prescaler now asks COVERAGE - if any covered source column is opaque, the destination is opaque and takes the blend of the covered samples. Verified by re-reading the shipped pack: 2 opaque columns, against 6 and 1 in the two builds before. Cost is printed permanently on every converter run: 122,113 texels rescued, 7.69% of the opaque census.

── 99 PATHS FOR A GAME WHOSE BIGGEST LEVEL HAS 21 ────────

AO's 46 per-level path arrays are declared [kMaxPaths] and land in HWRAM .data rather than .bss because their initialisers are non-zero: 97.1 KB in the map file, on a machine whose entire ao_new pool was 82 KB. kMaxPaths is 99 only because RELIVE stubbed Path_Get_Num_Paths to return it as a blanket bound - the real line is still there, commented out. Largest tables in the whole game are R1's and R2's at 21 entries; gMapData's field_18_num_paths agrees (20 R2, 11 D2, 9 F2, 6 R6).

99 -> 24. Pool 82.1 -> 159 KB, image 91,728 B smaller, and four flip-path loops stop walking 99 entries to look at 21. Safe because PathData and CollisionInfo are never indexed by a runtime path number - the PathBlyRec initialisers take their addresses at compile time, all 287 checked, highest is 20 - and the one runtime index now carries a named fatal.

Transferable bit: one non-zero initialiser moves a whole array from .bss to .data, i.e. out of free LWRAM and into the image. Run objdump -h before hunting a big culprit.

── LZ4 BACKGROUNDS, AND WHAT THE DRIVE ACTUALLY COSTS ────

.CAM backgrounds ship through an LZ4 container (BE raw length, then length-prefixed independent 8 KiB blocks). The converter decompresses every file it emits and refuses any that does not come back byte-identical. 49 R1 cameras, both languages: 4,779,860 -> 3,279,386 B (1.458x); R1.LVL 7,264,256 -> 5,761,024; largest record 165,364 -> 108,181. Cart slots 176,128 -> 131,072 B, 8 -> 12 resident screens, free.

LZ4 over deflate (1.701x) because the figure of merit is ratio MINUS decode, and deflate wants bit-wise Huffman on an SH-2 with no barrel shifter. The decoder has a longword fast path for literals and a deliberately byte-wise path for matches, since LZ4 matches overlap their own output; it typedefs its pointer width so the same source compiles for a 64-bit host and was verified against the shipped R1.LVL.

With the cache serving nearly every flip, the screen change became legible: the read is flat at 179-229 ms while the OPEN is 507-1364 ms, 73-86% of the whole change. Cost does not follow bytes - 16 KB costs 415 ms, 66 KB costs 1118 - which fits ~190 ms fixed plus ~71 KB/s, half what the same drive gives on large reads. Per-read overhead, not throughput.

── THE IN-PLAY SLOWDOWN, MEASURED ────────────────────────

From the capture in the video: full debug overlay, 3 min 43, sampled every 2 s, 111 frames, every row transcribed.

- Not accumulation. HWRAM free is HIGHER at 3:20 than at 1:00, LWRAM constant, and no capacity gauge is ever non-zero across all 111 samples. Nothing is dropped - frames are late.
- Fully reversible: returning to the opening screen restores exactly 30.0 fps.
- It follows CONTENT: 30.0 fps at 13-15 sprites, 14.3-16.7 fps at 42-64. Marginal cost ~1.2 ms, roughly 32,000 SH-2 cycles, per additional sprite.

Clock calibration, since cycle figures are worthless without it: the tick source is the free-running timer at PHI/128, so 1 raw tick = 128 SH-2 cycles and 208 ticks = 1 ms - 26,624 cycles/ms in 320-wide NTSC. NOT 28,636, which is the 352 clock; anything priced against that is 7.4% high.

One law I cannot explain yet: the ordering-table walk costs ~0.38 ms per SUBMITTED SPRITE and does not track prim count. 419 prims / 15 ms on one screen, 309 prims / 25 ms on another. Fewer prims, more time.

Which phase dominates a typical frame is deliberately not answered here: the overlay row I would have to quote is a per-screen MAXIMUM, and I do not yet trust myself to read a maximum as a mean. Next update.

── THE LCD MARQUEE IS 20% OF A FRAME ─────────────────────

With the marquee running, prim count, upload count, byte count and textured-rect count are all flat while the rate goes 24 -> 30 fps the instant the text finishes. Nothing else on the row moves.

The named cost is structural: the glyph atlas is read from VDP1 VRAM at 0x25C00000, the cache-through partition, so every source halfword is a real B-bus round trip that cannot be cached and contends with VDP1's own drawing. A scrolling panel re-composites every frame by construction. Lever: shadow the atlas in a cacheable bank.

Footnote: the blit has been bracketed and timed for dozens of builds, paying two clock reads a frame, and printed on no row. @slygamer had to ask why the LCD is expensive before I looked at my own instrument.

── EXODDUS, AND WHY IT IS ON A DIFFERENT BRANCH ──────────

Abe's Exoddus now reaches gameplay on the Saturn, on the same renderer, sound path and CD path as Oddysee.

It is built from RELIVE's beta branch rather than the master our Oddysee port sits on, for a simple reason: on master the Exoddus game code still carries 70 NOT_IMPLEMENTED sites, and on beta it carries none. Beta is 1,783 commits ahead. That is not a free upgrade - it brings JSON parsing at runtime, std::mutex and a thread pool, and it reworks the renderer seam from 28 virtuals down to 12 - so Oddysee is NOT migrating. Exoddus lives on its own branch in its own worktree, and the two games share the Saturn platform layer instead of sharing an engine revision.

The memory wall closed without needing the RAM cart. Biggest single lever was three lines of desktop code: <iostream> arriving through a logger header and an .ini writer pulled 586 KB of C++ stream machinery. Second was defining all fifteen of libstdc++'s std::__throw_* symbols plus the terminate handler - a static archive is pulled in one MEMBER at a time, so defining some of them still drags the member in for the rest, and defining all of them means the exception machinery, unwinder and name demangler included, is never linked at all. -95,808 bytes of text, in a program compiled -fno-exceptions.

What actually stopped it booting was not size, though. SGL's preloader runs before main and creates the malloc heap as (work area - end of .bss). Once .bss overran the window that subtraction went NEGATIVE and was passed as an unsigned size, so TLSF wrote its control block past the top of HWRAM - which mirrors back down onto our own .text. Dead before main, and it would have died at 900 KB exactly as surely as at 3 MB. Fixed by a linker map that puts .bss and the sbrk arena in LWRAM: .bss is NOLOAD, so it is the only section that can move without growing the binary.


── TWO THINGS I GOT WRONG ────────────────────────────────

1. The predictive prefetch. Speculate on the neighbouring screen's background while the player walks, serve the flip from RAM. On Ymir it did everything promised: the heavy flip 440 -> 200 ms, bytes read at the flip 70 -> 10 KB. On hardware @slygamer reported it slower on BOTH axes - longer loads and lower frame rate - against the same build with it off. Removed entirely. The precedent was already on the wall, which is why it shipped default-off behind a chord: a sibling project (Mimas, for a change) measured this exact shape, a persistent file handle kept busy, as a hardware regression on an SD ODE. Ymir could only ever confirm the mechanism RAN, never that it PAID. The captures did pay for the drive finding above.

2. The particle spawn cap. Halving blood and smoke per impact looked obvious. Putting the toggle state on the overlay row - the missing witness - made the A/B valid for the first time: frame rate 20/20/22/23 with the cap off, 22/20/23/24 with it on. Flat, if anything worse, while upload volume genuinely halves. Removed with its chord rather than left inert. The particles were never a speed problem, they were a sprite-count problem: one bullet asking for 20 of 60 slots, which is what the ceiling work fixed.

── STILL BROKEN, NAMED ───────────────────────────────────

- Frame rate: 23.7 fps average in-play, 14.3 worst, on screens carrying 40+ sprites.
- Loading: 22 screen changes in the capture, 40.82 s of black, mean 1.86 s, 17.7% of a 230.7 s run. Per-read overhead, per above.
- A white block replaces the barrel animation on some screens, visible in the video, and it is ours. Two doors of this bug are shut - a lamp pinning a CRAM bank for a whole run, and the per-screen VDP1 heap rewind handing the lamp's texture addresses to the next screen's uploads because those textures sit outside the slot table the rewind walks. Both fixes are in this build. Still there. Third instance, not found.
- Lamp halos are too wide. The originals are 8-12 pixels across; I widened ours for legibility and a fidelity pass is owed.
- New, and unexplained: a floor hatch that is orange on PSX renders VIOLET here. Measured on the raw captures, same room, same object - PSX hue 17 deg, RGB (112, 35, 26); Saturn hue 278 deg, RGB (49, 0, 76) at 0.97 saturation, green channel essentially zero. Looks much more like a palette entry taken from the wrong place than a conversion rounding issue, same family as two CRAM classification bugs I have already fixed, but unproven.
- @Wesker's hanging note, not audible in the video, that happens on mudokon save and secret room reveal: AO factored its note-on into a function whose signature carries neither the sequence index nor the MIDI channel, so it never writes the tag a sequence stop scans to decide which voices it owns. AE writes it, AO never has. Inaudible on PSX because the hardware kills the voice with a second key-off; here, 49 of the sound bank's live tones carry a release rate that works out to a 32.7 s quadratic ramp, so a released note is still at 99.2% volume three seconds later. Fixed the day after this capture.


── SINCE THIS CAPTURE ────────────────────────────────────

Three days of work did not make the video. Briefly, for the next update:

- All fourteen levels are on the disc now - 249.2 MB of PC data down to 77.3 MB of packs
- Nine of the 115 object factories demand a resource their own load branch never fetches, and five have no load branch at all. That is one design, not nine bugs, and hardening the check into a fatal was the wrong call.
- @Wesker's hanging note is fixed.
- Exoddus keeps moving: solid foreground, text, palette overrides, and a screen change split into drive time versus everything else.


── WITH THANKS ───────────────────────────────────────────

@slygamer: dozens of builds, dozens of captures, and the patience to run the same route every time so two builds are actually comparable. The capture carrying the whole frame-rate section is hers, and she killed the prefetch.

@Wesker: found the hanging note, which turned out to be a real PSX-vs-Saturn asymmetry in how a sequence stop reaches a voice.

@carlos24_: for this original post and port idea
 
Last edited:
Back
Top