Porting Hypnospace Outlaw's webpages to the real Web

Also see the README page for the tool's download link and how to use it.


Hypnospace Outlaw is a video game that takes place mainly in "hypnospace", a fictional version of the late-'90s Web that you access while sleeping. Hypnospace Outlaw was built in Construct 2, a game engine that exports to HTML5 + JavaScript. I love the aesthetic of the game's webpages (which are built in a custom .hsp format, which the game also ships a page builder for). Given that the game already builds to web technologies, I decided to port the format to the real Web.

Decompiling

data/read this.txt says that "we decided not to hide anything from players via encryption or whatever". They weren't kidding; package.nw is a plain zip containing a Construct 2 HTML5 export, unobfuscated save for Construct 2's built-in minification.

package.nw contains the following, among several more files:

Construct 2 minifies object-type names, but luckily keeps every string literal, numeric constant, and ACE reference. In data.js, project[5] contains 9 layouts (BootScreen, Menu, HypnOS, Highway, Dummy, RSOD, BIOS, Y2K, ModIO). project[6] also contains 16 event sheets. Since object-type names are minified, we understand them via their string literals and numeric constants. c2runtime.js ends with a cr.getObjectRefTable() that maps the numeric references in data.js back to real plugin/behaviour methods. In the repo, I wrote tools/c2decomp.py to join the two and print pseudo-code:

$ python3 tools/c2decomp.py work/pkg all | head -9
######## SHEET: BootScreen ########
INCLUDE sheet#INILoader
INCLUDE sheet#Debug
INCLUDE sheet#GlobalFuncs
VAR AppData = "" (type 1, static=False)
VAR AppRoot = "" (type 1, static=False)
VAR FilePrefix = "file:\\\\" (type 1, static=False)
VAR UserData = "" (type 1, static=False)
VAR OldUserData = "" (type 1, static=False)

Most of the functions that define the .hsp format are in the HypnOS sheet:

$ python3 tools/c2decomp.py work/pkg HypnOS | grep LoadElement -A 10
                  DO t2<Function>.Function.prototype.acts.CallFunction("LoadElement", ((($SlowLoadX + $loadOffset) % t149<Arr>.Arr.prototype.exps.Width()) + 1))
                  DO t2<Function>.Function.prototype.acts.CallFunction("UpdateZOrder", )
                  DO System.sys.acts.AddVar(var:SlowLoadX, 1)
                IF System.sys.cnds.Compare($SlowLoadX, combo:5, t149<Arr>.Arr.prototype.exps.Width())
                  DO System.sys.acts.SetGroupActive("Slow-load Webpage", combo:0)
                  DO t2<Function>.Function.prototype.acts.CallFunction("SyncGifs", )
                  DO t2<Function>.Function.prototype.acts.CallFunction("UpdateZOrder", )
                  DO t188<Sprite>.Sprite.prototype.acts.SetWidth(427)
                  DO t188<Sprite>.Fade.Fade.prototype.acts.SetFadeOutTime(0.5)
                  DO t188<Sprite>.Fade.Fade.prototype.acts.StartFade()
                  DO t192<Sprite>.Sprite.prototype.acts.SetAnim("Default", combo:0)

The notable functions I found were:

function / grouppurpose
LoadWebpagepath resolution
URLtoCurrentlinks are authored against a logical root
BuildWebpage, UpdatePagethe Webpage (x=0) fields: title, author, height×32, music, background, cursor
LoadElement, UpdateElementevery Gif and Text property
Element Animations and Effectsthe animation formulas
Load Gif / LoadGifsthe image registry: gifs/, static/, shapes/, wordart/, and .speed files
UpdateZOrderz-order is the array order reversed
ElementEventWebconditional state selection
ColorToRGBcolours are packed BGR
Scriptsthe cmd:param|cmd:param mini-language used by links and page onload

ReplaceText, which handles the /n, /t, /p, and #VAR# escapes, is native JavaScript (HypnoSpecial_ReplaceText in c2runtime.js) and not in the event sheet. Additionally, font geometry is stored as editor properties on Spritefontanim objects in the layout instance data in data.js.

Converting

Finally, I built the converter. tools/hspconv.py reads your page and the game's data/ directory and builds a browsable static site:

$ python3 tools/hspconv.py --data "Hypnospace Outlaw/data" --out site

hsp.js reimplements the parts of HypnOS that draw a page: Construct 2's Spritefont word-wrap and glyph blitting onto a canvas, the frame-sequence gif player, and the sway/spin/marquee/typewriter/colour-cycle animations, so pages move and link to each other the way they do in game. Modern browsers don't allow audio autoplay, so the first click or keypress allows music.

A .hsp file is a Construct 2 array export: a 300-px-wide page, a metadata row, then a list of Gif and Text elements, each carrying up to 20 conditional states (unlocked by story flags, so the same element can read differently once you've, say, gotten a game over). For each element the converter picks one state the way ElementEventWeb does, looks the element's image or font up in a registry built the same way LoadGifs builds it at startup (walking gifs/, static/, shapes/, wordart/), resolves any link the way URLtoCurrent does (against a logical root, then rewritten onto whichever capture directory the page actually is supposed to be in) and writes one HTML file per page, with a <div> per element positioned and z-ordered (array order, reversed) to match.

By default site/assets is just a symlink back into the game's data/, so the whole site is a few dozen MB and no asset is duplicated; --copy-assets gives you a self-contained directory instead.

tools/hspaudit.py re-reads the finished export and reports everything that failed to resolve. For example, when exporting the entire game's built-in pages:

$ python3 tools/hspaudit.py --site site --data "Hypnospace Outlaw/data"
pages: 1292   elements: {'text': 11979, 'gif': 14668}
unresolved images: 0 distinct, 0 references
unresolved fonts:  0 distinct, 0 references
links that resolve to no exported page: 13 distinct, 48 references

The 48 dead links are dead in-game too, I think they're pages that were cut from the shipped data but are still linked from somewhere (a the comic shop zone that doesn't exist, a couple of template2.hsp placeholders, one ~truetranquilityno.hsp).

Music

Pages also have music in two forms. 450 point at a plain .ogg, each with a sibling .txt of title|artist that the game's now-playing line reads — the converter just picks both up and the viewer plays them in a looping <audio>.

The other 527 point at a .hsm, which turned out to be another Construct 2 array, but encoding a small sample-based tracker module. The playback logic lives in the same HypnOS event sheet as everything else, so the same decompiling approach covered it: the Play Music Sounds group lays the pattern data out on three axes (step, pattern×track, parameter), the BPM Timer sheet gives secPerStep = 1 / (BPM/60) / 4 — a step is a sixteenth note — and a 60-entry FREQOUT table maps note number to playback (resampling) rate.

tools/hsmrender.py bounces each module down to a plain looping audio file offline, and we just ship that. It decodes every referenced sample once through ffmpeg, mixes the whole sequence with numpy at each note's resampled rate, and whatever's still ringing past the end of the sequence gets folded back over the start so the export loops seamlessly.