Ontwikkelingsupdate Shuusou Gyoku en Pensioenbekendmaking

Punten-items met negatieve waarden?

Ik ontdekte deze eigenaardigheid per ongeluk als resultaat van mijn code-opschoning uit 2024. Destijds bevatte de oorspronkelijke formatteringscode van pbg voor deze pop-ups van punten-items een van de talrijke code-analyse-waarschuwingen die ik besloot aan te pakken in plaats van te negeren:

- sprintf(buf, "%d", e->point); + sprintf(buf, "%u", e->point);

e->point is unsigned, wat er zeker op wijst dat je nooit punten-items met negatieve waarden kunt verzamelen, toch? Nou, ik was net een nieuw gewijzigd deel van de architectuur aan het testen toen dit gebeurde:

(Video-demonstratie van de incorrecte rendering van item-waarde pop-ups die negatieve punten representeren in de originele versie 1.005 van Shuusou Gyoku. Opgenomen door de eerste vijanden aan het begin van Stage 1 neer te schieten terwijl men in het midden van het speelveld blijft, en vervolgens tegen aangrenzende vijanden aan te lopen om onmiddellijk een leven te verliezen; dit zorgt ervoor dat VIVIT de punten-items verzamelt tijdens haar respawn-animatie terwijl ze zich onder de onderrand van het speelveld bevindt.)

Mooie underflow.

Verrassing: het spel begrenst de waarden van punten-items inderdaad niet tot 0! Een blik op de formule voor de verzamelwaarde vertelt ons direct wat er gebeurt:

(((469 - Viv.y) + (Viv.evade × 4)) × 160)

Dit werkt prima voor reguliere gameplay waarbij de Y-positie van VIVIT exact is begrensd op 469. Het verklaart ook de 0 punten die je krijgt voor het verzamelen van punten-items terwijl je onderaan het scherm "hugging" doet met een lege Evade-chain.

Maar zodra VIVIT met iets botst en een leven verliest, wordt haar Y-positie onmiddellijk ingesteld op 659, waarna ze met een snelheid van 1,5 pixel per frame omhoog beweegt. Dit creëert een venster van 127 frames waarin de puntenformule negatieve waarden produceert. Hoewel items verdwijnen zodra hun centrale Y-coördinaat > 487 is, heeft het weinig zin om te proberen dit venster te beperken tot minder frames:

  • Items verdwijnen pas na de botsingscontrole tegen VIVIT, waardoor ze één extra frame onder de 487 pixels hebben waarin VIVIT ze kan verzamelen.
  • Items versnellen met een snelheid van 3/64 of 0,046875 pixels per frame, tot een maximumsnelheid van 6 pixels per seconde. Aangezien VIVIT elk item verzamelt waarvan de centrale Y-positie 23,984375 pixels hoger is dan die van haarzelf, resulteert dit in (487 + 6 + 23,984375) = 516,984375 als de vroegste Y-positie waarop een respawnende VIVIT items kan verzamelen, wat het venster reduceert tot slechts de 32 frames waarin VIVIT tussen de 516,5 en 470 pixels zit.

Maar zelfs met die limiet in gedachten kunnen stage-scripts vijanden spawnen ver buiten de grenzen van het speelveld, en schoten en bommen kunnen ze daar vrijuit vernietigen.

Overduidelijk moeten we iets doen aan dit probleem. Maar wat, en hoe?

Ten eerste definieert het 5×7-pixel lettertype dat gebruikt wordt voor deze pop-ups geen sprite voor een minteken. De oorspronkelijke %d format-specifier voegde wel een minteken toe aan de string, maar de popup-renderer sloeg elk niet-numeriek teken simpelweg over. Deze negatieve waarden van punten-items maakten altijd al deel uit van het spel, maar door het ontbreken van een zichtbaar minteken mengden hun pop-ups zich met de reguliere positieve. Als code-analyse me niet had overtuigd om %d naar %u te veranderen, hadden we dit hele probleem misschien nooit opgemerkt.

Ten tweede kan de score die linksboven in het speelveld wordt weergegeven alleen toenemen en nooit afnemen. Negatieve scorepunten "bestaan" daarom eigenlijk alleen als onderdeel van de score-delta, waar ze punten absorberen van andere scorebronnen totdat de delta weer positief wordt.

We zouden elke negatieve delta direct kunnen reflecteren in de getoonde totale score. Echter, dat getal is niet zomaar een tussentijdse waarde, maar representeert de feitelijke interne en officiële score die wordt geregistreerd in de High Score-lijst, en consequent ook in het toekomstige replay-formaat. Als er een codepad is waarbij de delta niet volledig aan de score wordt toegevoegd voordat het spel eindigt – zoals aan het einde van Stage 6 en Extra van TH04 – zouden we eindigen met een andere score dan dezelfde gameplay zou hebben gekregen in de originele build van pbg.

En inderdaad, Shuusou Gyoku heeft hetzelfde probleem. De delta wordt alleen volledig toegevoegd aan de officiële score bij een Game Over, en verder alleen geleidelijk toegevoegd als onderdeel van de update-functie van VIVIT, in stappen van maximaal 100.000 punten per frame. Als we elke negatieve delta onmiddellijk zouden vastleggen en een speler stopt direct nadat hij een punten-item met een negatieve waarde heeft verzameld, zou de officiële score van die gedeeltelijke run lager zijn dan in het originele spel. Dit maakt nu niet uit omdat Shuusou Gyoku geen scores in de High Score-lijst zet als je een ronde afbreekt, maar het zal wel uitmaken zodra we dergelijke gedeeltelijke runs als replays gaan opslaan, wat we overduidelijk willen doen. Maar zelfs 떠apart daarvan is het uiteindelijk een te klein probleem om enige wijziging in de goed gedefinieerde scoring-aspecten van het spel te rechtvaardigen, of om een nieuwe inconsistentie tussen de getoonde score en de geregistreerde score te introduceren.

Hierdoor blijven we over met het oplossen van de rendering-bug van de ontbrekende mintekens, wat eenvoudig is opgelost door een van de diverse 5×1 pixel lijnen in #DCDCFE van de systeem-sprite-sheet te hergebruiken.

Dus, laten we de game-logica van Shuusou Gyoku opsplitsen in een bibliotheek! Stap 1:

Het definiëren van de reikwijdte van "game-logica"

Aan de ene kant lijkt het antwoord simpel: de functie GameMove() somt netjes alle subsystemen op die betrokken zijn bij een frame van gameplay. Dus laten we ze allemaal nemen, ze verplaatsen naar een DLL, en dan zijn we klaar, toch? Er zijn echter verschillende goede redenen om niet zomaar deze lijst met subsystemen te nemen, maar in plaats daarvan het werkelijke minimum aan gameplay-gerelateerde code te identificeren en af te splitsen:

  1. Performance. Ideaal gezien zouden we deze geïsoleerde game-logica kern gebruiken voor massale validatie van elke nieuwe release tegen elke ooit gemaakte replay. De weinige onnodige subsystemen voegen misschien maar een paar milliseconden per replay toe, maar dat telt snel op op deze schaal.
  2. Thread-safety, parallellisme en de bijbehorende refactoring-inspanning. pbg bewaarde de meeste gameplay-status in globale variabelen, dus we willen dit allemaal bundelen in één enkele C++ klasse die we meer dan eens kunnen instantiëren. Aangezien het opstarten van een proces op Windows 30-40 ms kost, is het veel wenselijker om de replay-validator multi-threaded te maken. Het minimaliseren van het aantal beïnvloede subsystemen minimaliseert de hoeveelheid noodzakelijke refactoring-arbeid.
  3. Backwards seeking voor replays. De genoemde refactoring-arbeid zou ook de basis leggen voor de verschillende "keyframe" snapshots van game-data die nodig zijn om die functie met vlottere framerates te laten werken. We kunnen het nu maar meteen correct doen.

Als we alle logica-code in de nieuwe ssg/ directory plaatsen en GIAN07/ alleen met de presentatie-aspecten achterlaten, kan iedereen die met deze codebase werkt onmiddellijk zien dat het wijzigen van code in ssg/ het riskeert de gameplay te forken en bestaande replays te breken. Maar de begeleiding en impliciete documentatie die we krijgen van deze fysieke scheiding kan alleen betekenisvol zijn als we een inspanning leveren om de hoeveelheid code in ssg/ te minimaliseren en de hoeveelheid vrij modbare code en data in GIAN07/ te maximaliseren.

Terwijl de game-logica DLL zich zal identificeren met het ReC98 release-versienummer, ben ik ook van plan een hash van alle whitespace-gestripte game-logica broncode hard te coderen. Deze hash wordt vervolgens aan elke replay toegevoegd als een tweede laag die de game-build kan identificeren waarmee een replay is opgenomen. Hierdoor minimaliseert het beperken van de hoeveelheid gehashte code de kans dat deze identificerende hash verandert tussen versies of forks.

Bovenaan GameMove() zie je een aanroep naar MWinMove(), die de dialoogvensters van het spel verwerkt. Door mijn nieuwe, lui-bijgewerkte immediate-mode benadering van tekst-rendering, vereist elk tekstvak momenteel een dynamisch gealloceerde std::string om de exacte inhoud vast te houden. Dialoog is echter ook het enige subsysteem dat in GameMove() wordt aangeroepen dat tijdens de gameplay überhaupt dynamisch alloceert. Als we kunnen bewijzen dat dit hele systeem de game-logica niet beïnvloedt, zelfs niet met onze geplande ondersteuning voor het tonen van dialoog in het nieuwe replay-formaat, hebben we een goede reden om het in de frontend te houden. En aangezien we dat kunnen bewijzen, komt dit een heel eind in de richting van het mooie bonusdoel om de C/C++ standaardbibliotheek bloat (en dus, OS API-afhankelijkheden) in deze toekomstige DLL te verminderen.

En als we al één subsysteem niet willen includeren, kunnen we net zo goed het correcte aantal subsystemen niet includeren.

Kaarttegels (map tiles) zijn waarschijnlijk het meest opvallende geval van decoratieve data die exclusief voor de frontend blijft. Aangezien VIVIT er nooit om geeft waar ze over vliegt, hoeft de nieuwe logicalaag ze niet alleen niet te verwerken en hun scrolling niet te doen, maar hoeft hij zelfs geen poging te doen om per-stage map-data te laden en te decompresseren. Alles wat hij nodig heeft zijn de ECL- en SCL-scripts uit ENEMY.DAT.

Dit laat ons achter met een vrij beknopte lijst van subsystemen die de game-logica van Shuusou Gyoku vormen:

  • Bosses
  • Bullets
  • Enemies, en hun ECL-scripts
  • Enemy sprite-groottes, aangezien het spel hitbox-groottes hieruit afleidt. Helaas zijn deze hardcoded voor elke stage.
  • Items
  • Alle drie de typen lasers (lineair bewegend, homing, en stationair)
  • Player shots
  • Rank
  • De snake controller voor de Stage 3 midboss
  • De ene en enige VIVIT
  • De tekst pop-ups die getoond worden bij het verslaan van een boss, Evade-bonussen, of bij het verzamelen van een bom of extra leven, waarbij elke letter in een andere hoek wegvliegt… wacht, wat?!

Zelfde frame in de stage, andere seed.

Jazeker. Shuusou Gyoku gebruikt één enkele RNG voor alles, van overduidelijke gameplay-aspecten tot decoratieve particles. Het eerste wat je leert over de soort pseudo-random number generators die in replaybare games worden gebruikt, is dat ze stateful zijn: ze initialiseren met een gegeven seed tot dezelfde reeks waarden, en elke aanroep naar de rand()-equivalent verplaatst de interne status naar het volgende getal in de reeks. Hierdoor eindigen we met een duidelijk gedefinieerde volgorde:

… 31305 22709 32031 30508 339 32731 29375 30469 14300 …

(Een representatieve reeks willekeurige getallen getrokken tijdens een frame aan het begin van Stage 4: Respawned rocks → Enemy initialization → ECL script → Evade particles)

Daarom moet elk enkel subsysteem dat mogelijk een getal uit de RNG-reeks trekt, per definitie als game-logica worden beschouwd. Als een van deze subsystemen:

  1. in een andere volgorde wordt verwerkt (wat een natuurlijk neveneffect zou zijn van het netjes splitsen van game-logica en schijnbare presentatie), of
  2. helemaal niet wordt verwerkt (wat het geval zou zijn voor een CLI-tool die alleen de game-logica laag draait),

dan komt de resulterende reeks willekeurige getallen niet meer overeen met de reeks waarmee bestaande replays zijn opgenomen, wat leidt tot een meer of minder onmiddellijke desync.

Dit is overigens dezelfde reden waarom de netplay voor TH03 veel langer vertraging zou hebben opgelopen dan oorspronkelijk voorzien. Aangezien TH03 ook slechts één RNG gebruikt voor zowel gameplay als visuele elementen, en rollback een niet-onderhandelbare basisfunctie is van netplay, had ik RE-tijd moeten besteden aan het isoleren van RNG-aanroepen in rendering-functies en deze moeten verplaatsen naar hun respectievelijke update-functies.

En plotseling groeit deze lijst met een paar decoratieve aspecten:

  • De harten die getoond worden bij het verliezen van een leven
  • De vonken die getoond worden bij het grazen van bullets of het raken van een vijand
  • De vallende rotsen in Stage 4
  • De sterren die getoond worden bij het verzamelen van punten-items tijdens een Evade-combo, of bij het gebruik van de Wide Shot bom
  • De vallende sterren in de laatste fase van het gevecht met de Gates, en in Stage 6
  • Boss-explosies
  • De achtergrondanimaties in Stage 6: de golvende