From 67e2a1ddcdfc9e40488935886f612c464cc7d30f Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sat, 26 Sep 2026 19:57:06 -0500 Subject: [PATCH] More video optimizations. Font rendering and GUI optimizations. Logo updates. Manual updates. Lots of stuff! --- CHANGELOG | 477 +-- CMakeLists.txt | 34 +- INSTALL | 12 +- LICENSES | 2 +- assets/Backdrop.singe | 56 +- assets/DragonModel.glb | 4 +- assets/Menu.singe | 3 + assets/MenuOverlay.singe | 34 +- assets/Tools.singe | 10 +- assets/controls.cfg | 4 +- assets/menuBackground.mkv | 4 +- assets/settings.cfg | 12 +- build-all.sh | 4 +- cmake/Superbuild.cmake | 16 +- cmake/shaderHeader.cmake | 16 +- docs/Forge.adoc | 47 +- docs/ForgeVocabulary.adoc | 8 +- docs/Manual.adoc | 603 +++- docs/Reference.adoc | 344 ++- docs/lessons/19-text.adoc | 15 +- {assets/Forge => forge}/Author.singe | 120 +- {assets/Forge => forge}/AuthorCompile.singe | 0 {assets/Forge => forge}/Forge.rml | 0 {assets/Forge => forge}/Forge.singe | 9 +- {assets/Forge => forge}/games.dat | 0 {assets/Forge => forge}/kit/LICENSE | 0 {assets/Forge => forge}/kit/README.txt | 0 {assets/Forge => forge}/kit/click.wav | 0 {assets/Forge => forge}/kit/clip.mkv | 0 {assets/Forge => forge}/kit/coin.png | 0 {assets/Forge => forge}/kit/coin.wav | 0 {assets/Forge => forge}/kit/crate.png | 0 {assets/Forge => forge}/kit/door.png | 0 {assets/Forge => forge}/kit/door.wav | 0 {assets/Forge => forge}/kit/enemy.png | 0 {assets/Forge => forge}/kit/fox.glb | 0 {assets/Forge => forge}/kit/hall.png | 0 {assets/Forge => forge}/kit/hero.png | 0 {assets/Forge => forge}/kit/hit.wav | 0 {assets/Forge => forge}/kit/jump.wav | 0 {assets/Forge => forge}/kit/key.png | 0 {assets/Forge => forge}/kit/loop.ogg | 0 {assets/Forge => forge}/kit/rock.png | 0 {assets/Forge => forge}/kit/ship.png | 0 {assets/Forge => forge}/kit/shot.png | 0 {assets/Forge => forge}/kit/shot.wav | 0 {assets/Forge => forge}/kit/spike.png | 0 {assets/Forge => forge}/kit/target.png | 0 {assets/Forge => forge}/kit/tiles.png | 0 {assets/Forge => forge}/kit/yard.png | 0 .../tutorials/01-tenMinutes.forge | 0 .../tutorials/02-platformer.forge | 0 .../tutorials/03-sprites.forge | 0 .../Forge => forge}/tutorials/04-shmup.forge | 0 .../Forge => forge}/tutorials/05-qte.forge | 0 .../Forge => forge}/tutorials/06-gun.forge | 0 .../tutorials/07-adventure.forge | 0 .../Forge => forge}/tutorials/08-scene.forge | 0 .../Forge => forge}/tutorials/09-rail.forge | 0 .../tutorials/10-release.forge | 0 {assets => media}/Dragon.jpeg | 0 {assets => media}/Dragon.mtl | 0 {assets => media}/Dragon.obj | 0 {assets => media}/Dragon.svg | 0 {assets => media}/DragonModel.mtl | 0 {assets => media}/DragonModel.obj | 2737 +++++++++-------- {assets => media}/DragonPivots.json | 1 + {assets => media}/DragonPrint-champagne.stl | 0 {assets => media}/DragonPrint-dark.stl | 0 {assets => media}/DragonPrint-frame.stl | 0 {assets => media}/DragonPrint-glass.stl | 0 {assets => media}/DragonPrint.mtl | 0 {assets => media}/DragonPrint.obj | 0 {assets => media}/DragonRelief.glb | 0 {assets => media}/Plaque-champagne.stl | 0 {assets => media}/Plaque-dark.stl | 0 {assets => media}/Plaque-frame.stl | 0 {assets => media}/Plaque-glass.stl | 0 {assets => media}/Plaque-plate.stl | 0 {assets => media}/Plaque.mtl | 0 {assets => media}/Plaque.obj | 0 {assets => media}/Service.singe | 0 {assets => media}/SingeText.jpeg | 0 {assets => media}/SingeText.mtl | 0 {assets => media}/SingeText.obj | 0 {assets => media}/SingeText.svg | 0 {assets => media}/SingeTextPrint-face.stl | 0 {assets => media}/SingeTextPrint-plate.stl | 0 {assets => media}/SingeTextPrint-walls.stl | 0 {assets => media}/SingeTextPrint.mtl | 0 {assets => media}/SingeTextPrint.obj | 0 .../eliza_music-neons-in-napoli-539381.mp3 | 3 + ...unky-groove-90s-dance-club-beat-533491.mp3 | 3 + ...n-video-game-music-no-copyright-426130.mp3 | 3 + .../samples/dragonFireBreathAndRoar.flac | 0 .../samples/explosionUltraBass.flac | 0 {assets => media}/samples/largeFireball.flac | 0 src/gui.cpp | 45 +- src/gui.h | 1 + src/guiRender.cpp | 110 +- src/guiRender.h | 14 +- src/main.c | 198 +- src/main.h | 1 + src/render.c | 89 + src/render.h | 44 +- src/renderGles.c | 934 +++++- src/renderGlesApi.h | 51 + src/renderGlesLoad.c | 28 + src/scene.c | 96 +- src/scene.h | 1 + src/shaders/scene.hlsl | 34 +- src/singe.c | 1276 +++++++- src/singe.h | 6 + src/util.c | 29 + src/util.h | 2 + src/vfs.c | 80 +- src/videoPlayer.c | 806 ++++- src/videoPlayer.h | 6 +- testScripts/Models/DragonModel.glb | 4 +- testScripts/menuIntro.singe | 10 +- testScripts/ports/compare.sh | 12 +- testScripts/ports/standalone.txt | 22 + testScripts/ports/sweep.sh | 44 +- thirdparty/ffmpeg/libavcodec/rkmppdec.c | 8 +- util/dragonModel.py | 20 +- util/dragonObj.py | 4 +- util/forgeKit.py | 4 +- util/forgePortKaris.lua | 28 +- util/forgePorts.py | 2 +- util/forgeStandalone.py | 24 +- util/forgeVocabulary.lua | 6 +- util/makeMenuSound.py | 4 +- util/plaqueModel.py | 4 +- util/reelMusic.py | 987 ++++++ util/reelSamples.py | 244 ++ util/textModel.py | 6 +- util/traceDragon.py | 4 +- util/traceText.py | 4 +- 138 files changed, 7339 insertions(+), 2534 deletions(-) rename {assets/Forge => forge}/Author.singe (99%) rename {assets/Forge => forge}/AuthorCompile.singe (100%) rename {assets/Forge => forge}/Forge.rml (100%) rename {assets/Forge => forge}/Forge.singe (99%) rename {assets/Forge => forge}/games.dat (100%) rename {assets/Forge => forge}/kit/LICENSE (100%) rename {assets/Forge => forge}/kit/README.txt (100%) rename {assets/Forge => forge}/kit/click.wav (100%) rename {assets/Forge => forge}/kit/clip.mkv (100%) rename {assets/Forge => forge}/kit/coin.png (100%) rename {assets/Forge => forge}/kit/coin.wav (100%) rename {assets/Forge => forge}/kit/crate.png (100%) rename {assets/Forge => forge}/kit/door.png (100%) rename {assets/Forge => forge}/kit/door.wav (100%) rename {assets/Forge => forge}/kit/enemy.png (100%) rename {assets/Forge => forge}/kit/fox.glb (100%) rename {assets/Forge => forge}/kit/hall.png (100%) rename {assets/Forge => forge}/kit/hero.png (100%) rename {assets/Forge => forge}/kit/hit.wav (100%) rename {assets/Forge => forge}/kit/jump.wav (100%) rename {assets/Forge => forge}/kit/key.png (100%) rename {assets/Forge => forge}/kit/loop.ogg (100%) rename {assets/Forge => forge}/kit/rock.png (100%) rename {assets/Forge => forge}/kit/ship.png (100%) rename {assets/Forge => forge}/kit/shot.png (100%) rename {assets/Forge => forge}/kit/shot.wav (100%) rename {assets/Forge => forge}/kit/spike.png (100%) rename {assets/Forge => forge}/kit/target.png (100%) rename {assets/Forge => forge}/kit/tiles.png (100%) rename {assets/Forge => forge}/kit/yard.png (100%) rename {assets/Forge => forge}/tutorials/01-tenMinutes.forge (100%) rename {assets/Forge => forge}/tutorials/02-platformer.forge (100%) rename {assets/Forge => forge}/tutorials/03-sprites.forge (100%) rename {assets/Forge => forge}/tutorials/04-shmup.forge (100%) rename {assets/Forge => forge}/tutorials/05-qte.forge (100%) rename {assets/Forge => forge}/tutorials/06-gun.forge (100%) rename {assets/Forge => forge}/tutorials/07-adventure.forge (100%) rename {assets/Forge => forge}/tutorials/08-scene.forge (100%) rename {assets/Forge => forge}/tutorials/09-rail.forge (100%) rename {assets/Forge => forge}/tutorials/10-release.forge (100%) rename {assets => media}/Dragon.jpeg (100%) rename {assets => media}/Dragon.mtl (100%) rename {assets => media}/Dragon.obj (100%) rename {assets => media}/Dragon.svg (100%) rename {assets => media}/DragonModel.mtl (100%) rename {assets => media}/DragonModel.obj (52%) rename {assets => media}/DragonPivots.json (96%) rename {assets => media}/DragonPrint-champagne.stl (100%) rename {assets => media}/DragonPrint-dark.stl (100%) rename {assets => media}/DragonPrint-frame.stl (100%) rename {assets => media}/DragonPrint-glass.stl (100%) rename {assets => media}/DragonPrint.mtl (100%) rename {assets => media}/DragonPrint.obj (100%) rename {assets => media}/DragonRelief.glb (100%) rename {assets => media}/Plaque-champagne.stl (100%) rename {assets => media}/Plaque-dark.stl (100%) rename {assets => media}/Plaque-frame.stl (100%) rename {assets => media}/Plaque-glass.stl (100%) rename {assets => media}/Plaque-plate.stl (100%) rename {assets => media}/Plaque.mtl (100%) rename {assets => media}/Plaque.obj (100%) rename {assets => media}/Service.singe (100%) rename {assets => media}/SingeText.jpeg (100%) rename {assets => media}/SingeText.mtl (100%) rename {assets => media}/SingeText.obj (100%) rename {assets => media}/SingeText.svg (100%) rename {assets => media}/SingeTextPrint-face.stl (100%) rename {assets => media}/SingeTextPrint-plate.stl (100%) rename {assets => media}/SingeTextPrint-walls.stl (100%) rename {assets => media}/SingeTextPrint.mtl (100%) rename {assets => media}/SingeTextPrint.obj (100%) create mode 100644 media/music/eliza_music-neons-in-napoli-539381.mp3 create mode 100644 media/music/miiira-vintage-funky-groove-90s-dance-club-beat-533491.mp3 create mode 100644 media/music/poorartistt-game-is-on-video-game-music-no-copyright-426130.mp3 rename {assets => media}/samples/dragonFireBreathAndRoar.flac (100%) rename {assets => media}/samples/explosionUltraBass.flac (100%) rename {assets => media}/samples/largeFireball.flac (100%) create mode 100644 testScripts/ports/standalone.txt create mode 100644 util/reelMusic.py create mode 100644 util/reelSamples.py diff --git a/CHANGELOG b/CHANGELOG index 72a834e16..2390c5d27 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -9,6 +9,53 @@ SINGE 3.00 API Changes ----------- +- controls.cfg, settings.cfg and gamecontrollerdb.txt are read game first. + The four places are the same, but the file beside the game's script -- + what its author shipped -- is now read first, then the machine's own in + the directory Singe was started in, then the one above the data directory, + then the game's data directory, and a later file still wins. Before, the + game's file came last and beat the machine's, so a handheld could not keep + a game from putting its test mode on a face button. A machine file only + replaces what it names: INPUT_TEST_MODE = { SCANCODE.F2 } takes test mode + off whatever button a game gave it. + +- gamecontrollerdb.txt now beats SDL_GAMECONTROLLERCONFIG. Systems that set + that variable for their built-in pad -- ArkOS does, with a mapping for the + R36S that leaves Select and Start out -- used to win over any file, so no + game ever saw those buttons. The files the first game finds are added to + the variable before SDL starts, where a later line for a pad replaces an + earlier one. With --program, a pad button its mapping leaves out is traced + with its raw number, so a gamecontrollerdb.txt line can name it. + +- A controls.cfg input can be a chord: { GAMEPAD_0.BUTTON_BACK, + GAMEPAD_0.BUTTON_START } as one entry of a mapping fires when the buttons + are held together. Each button keeps its own meaning as well. + +- fontPrint keeps the strings it renders, keyed by font, quality, colours and + text, so printing the same text every frame costs about what drawing a + sprite of it does. Rendering text with fontToSprite for performance is + deprecated; it still works, and is still the way to get text that can be + measured, scaled or turned. Strings not printed for a second are dropped, + and a font's go when it is unloaded. + +- GUIs are redrawn only when their picture changes. When a frame's document + draws exactly what the last frame drew and nothing new was made in between, + the texture already holds the picture and is kept; changes through RML, + RCSS, the RmlUi Lua interface and the gui* calls are all seen. + guiRefresh(gui) forces a redraw, as a way out should a change ever be + missed; a game should not need it. guiGetStats reports zeros on a frame + where nothing was redrawn. + +- videoDraw has the GPU draw a video that is not scaled or turned by + videoScale or videoRotate, when it lands on the overlay where nothing has + been drawn since an overlayClear to a transparent colour: under the + overlay, where it looks the same as the frame copied in and costs next to + nothing. Such a frame is scaled to the screen the way the overlay is, + rather than nearest-neighbour into the overlay first. Loaded videos now + decode the way the disc does, including zero-copy, and are converted to + RGB on the CPU only when a CPU draw needs the pixels, so draw a video + before anything that goes over it rather than after. + - Singe can update itself. Once a day the menu asks the master service whether a newer release exists for this machine's platform, and says so in a line under the game's details; nothing is fetched until a person opens @@ -89,7 +136,7 @@ API Changes virtual clock like everything else, each frame lasting the time it is worth, and the script runs every step. A bare os.time() answers with the virtual clock too, so a game that seeds its generator from - the wall clock when a game starts (the one-player Space Pirates) + the wall clock when a game starts (a one-player game does) repeats; os.time with a date table is Lua's own. - fontSelect given no font (nil, or an index that was never loaded) @@ -612,7 +659,7 @@ API Changes packed games can be modded by dropping files beside them and files a game writes land under data//files. Inside a database names are matched without regard to case and a leading own-directory prefix - ("DLe/Cfg/x") is ignored. singe --unpack GAME.game DIRECTORY restores + ("MyGame/Cfg/x") is ignored. singe --unpack GAME.game DIRECTORY restores the files and singe --patch GAME.game SOURCE replaces files from a directory or a patch database in one transaction. --entry=N picks which games.dat entry a .game launch runs. Loose games are @@ -733,7 +780,7 @@ API Changes that layout; its saves now belong under //Game/ and must be moved by hand. Archives whose script sits at the root are unaffected. -- Packed games: a path naming the .game file itself (Games/DLe.game, +- Packed games: a path naming the .game file itself (Games/MyGame.game, with or without a trailing separator) addresses the database root, as "." does from inside: lfs.dir lists it and lfs.attributes reports a directory, while io.open on it returns nil and lfs.mkdir and lfs.rmdir @@ -1032,6 +1079,82 @@ API Changes New Features ------------ +- An OpenGL ES 3.1 backend for the 3D scene and the GUI. SDL_GPU has + Vulkan, Direct3D 12 and Metal and no OpenGL of any kind, which left the + cheap Linux handhelds and the Raspberry Pi with 2D only: their Mali and + VideoCore parts have a mature GLES driver and either no Vulkan or an + immature one. The subset of SDL_GPU the scene and the GUI use is now + behind one table of function pointers with two implementations, so both + files stay the single description of what Singe draws, and the backend is + chosen at run time -- one aarch64 binary serves a board with Vulkan and one + without. The ES shaders come from the same HLSL through SPIRV-Cross, and + the GL entry points are loaded through SDL rather than linked, so a machine + with no GLES driver simply keeps its 2D renderer. The Raspberry Pi 4 + remains the minimum for 3D: the Pi 3 has only OpenGL ES 2.0, which cannot + express the scene's skinning or morph targets. + +- Singe runs on the R36S under ArkOS, and on the Rockchip handhelds like it. + On KMSDRM the OpenGL ES context is asked for before the window is made and + SDL's GLES renderer is chosen first, where SDL used to ask EGL for desktop + OpenGL and fail to make a window. The scene's instance data goes through + texture buffers, since Mali has no storage buffers in vertex shaders; + uniforms are written without waiting for the GPU, uploads orphan their + buffers, and targets are invalidated when a frame is done with them, which + is what a tiling GPU needs to keep from copying. + +- Compiled GLES shader programs are kept in the Singe folder of the data + directory and loaded from there next time, and the programs a scene will + need are built at the start rather than on the frame that first uses them, + so the first seconds of a game no longer stutter on a small GPU. + +- --scenequality low draws the 3D scene at half resolution, without + antialiasing or bloom, for small GPUs. + +- --zerocopy (on by default) hands hardware decoded frames to the GPU as + dma-bufs instead of copying them: Rockchip's MPP on the handhelds, VAAPI on + desktops, imported on the device the picture is drawn on. Where the GPU or + the decoder cannot, frames are copied as before. Rockchip's decoder now + hands out cached buffers, so frames that are copied are read far faster. + +- --quitchord=BUTTONS names gamepad buttons that quit when held together, + whatever the game maps and even when it has turned its quit key off: a game + quits back to the menu it came from, and the menu quits Singe. A press of + one of the chord's buttons waits a quarter of a second for the rest before + it does what it normally does, so Start does not launch a game from the + menu on the way to quitting it. + +- SIGTERM and SIGINT end all of Singe: the running game shuts down as usual, + and the menu it came from does not come back. A second signal while that + is under way ends the process at once. + +- --program traces where each frame's time went, every five seconds: per + loop, the disc, the script, the scene, the GUIs, the overlay upload and the + present, with the script split into its Lua and the engine calls it made, + costliest first, and a line for each video's decoding. --perfsync makes + each stage wait for the GPU, so its time shows up in the stage that caused + it rather than all in present. The manual explains the lines. + +- A crash prints a backtrace, on Linux and macOS, to the console and to + trace.txt, so a report can say where it happened and trace.txt alone is a + whole report. + +- A resolution larger than the display is fitted to it instead of refused. + +- Videos loaded with videoLoad build their frame index in the background, so + loading one no longer holds the frame. The audio queue is longer, which + stops the breakup a slow machine suffered while a video decoded. + +- A damaged packet in a video is skipped instead of ending the game; a stream + that keeps failing still does. + +- Printing and clearing the overlay cost far less. overlayClear refills only + what was drawn since the last clear, when the colour is the same, and the + overlay is sent to the GPU only where it changed. On the R36S, Dragon's + Lair Enhanced went from 31-38 frames a second to the display's full rate. + +- The menu no longer needs its background video as the disc; Menu.sh passes + its own arguments on to Singe. + - Forge can judge a shot by the picture rather than by a hitbox. lightSensor is a behaviour of the framework now, the way a home laserdisc gun works: a spot on the picture flashes on the frames a target is shown, and a shot is @@ -1104,8 +1227,8 @@ New Features stop, from an entity or a list of them in turn, or at a point; and soundDone says when a clip playSound or a sound behaviour started has ended. Ports of the test library began (FORGE.md section 14): the - five ActionMax games are Forge descriptions written by - util/forgePortActionMax.py, and play the same as the emulator under + five light gun games of the VHS era are Forge descriptions written by + a porting script, and play the same as the emulator under the same inputs (testScripts/ports/). On the way: a lightSensor behaviour, a pointer behaviour, fonts and anchors on the text look, sprites drawn in copies, discPlay and discPause, pointerX and pointerY @@ -1116,17 +1239,14 @@ New Features conditions, actions, layers, or events to the manifest for that game alone; the editor loads it when the game opens and forgets it when the game closes, the compiler checks against it, and the built game - carries it into a release. The ActionMax light sensor lives in one - now, beside those five games, and not in Forge. The Hypseus library's - eleven Karis Framework 3.32b titles (Arcade Xperience 1 to 3, Astroboy, - Danmachi, Freddy, Starship Troopers, Sugar Rush, Survival, Esh's - Aurunmilla, and Badlands Lite) are ported by the same converter, which + carries it into a release. Their light sensor lives in one now, + beside those five games, and not in Forge. The Hypseus library's + eleven Karis Framework 3.32b titles are ported by the same converter, which finds the framework where a game keeps it and notes its version; the rest of that library is surveyed in FORGE.md 14.9. A branch on the branching track can want its move mashed (mash = 6) or held (hold = - 20). The seven Kimmy Script Engine titles (Brain Dead 13, Future Boy - Conan, Mazinga Z, Fuma Conspiracy, Sonic 1996, Sintel, Ninja Hayate - 1080) are ported by the same converter and a kimmy behaviour that + 20). The seven Kimmy Script Engine titles are ported by the same + converter and a kimmy behaviour that plays that engine's loop, and play as their originals do (FORGE.md 14.11). util/forgePorts.py @@ -1270,12 +1390,13 @@ New Features - The menu's intro is drawn, not played, and runs straight into the menu. A charge goes off, the Singe dragon and name fly out of it toward the camera with the dragon's wings beating, the tagline fades in, the dragon - rears and breathes flame -- and then the grid the menu sits on comes up - out of the dark beneath the logo while the smoke is still clearing, the - logo lifts away, and the menu takes the screen. The dragon settles - centred on the sun rather than above it, with the name below crossing the - horizon, so the sun is a disc behind the animal instead of a rim around - the pair of them. It is one scene from the + rears and roars with its mouth open, then rears again and breathes flame + -- and then the grid the menu sits on comes up out of the dark beneath + the logo while the smoke is still clearing, the logo lifts away, and the + menu takes the screen. The dragon faces left and settles centred on the + sun rather than above it, with the name below crossing the horizon, so the + sun is a disc behind the animal instead of a rim around the pair of + them. It is one scene from the first frame to the last: there is no cut and nothing loads at the join, and the menu takes input when the logo has gone rather than when a video frame number passes. Returning from a game starts the same timeline past @@ -1394,7 +1515,9 @@ New Features the top of the neck; before, every part's node sat at the model's origin and turning a wing swung it around the middle of the animal. The model looks identical -- no part moves by so much as a thousandth -- but it can - now be animated without rigging it again in script each time. + now be animated without rigging it again in script each time. The lower + jaw is a part of its own, hinged at the back of the mouth, so the mouth + can open; the printable variants keep it in the head. - The manual's 3D Scenes, Physics and Particles chapters are rewritten as sections in a learning order with worked examples, every reference @@ -1482,28 +1605,6 @@ New Features Fixes ----- -- Two things on the engine update's Windows path. Windows cannot replace a - running image, so the update starts the new Singe beside this one and lets - this one end; but with a console -- the default -- this one then sat in - getchar() waiting for a key, so two engines were alive at once and the old - one held the console the new one was writing to. The key wait now happens - only when nothing took over, and still happens when the start failed, since - that message is worth reading. And setting the running binary aside as - old- ignored whether the rename worked. It usually does: renaming a - running executable is allowed on Windows as it is everywhere, as long as the - new name is on the same volume, and only deleting one is refused. It can - still be beaten by a folder the user cannot write or by a scanner holding the - file, and that was silent. The tool now says so, on screen and in the trace, - and carries on: the launcher runs the newest Singe in the folder, so the old - binary left in place is harmless. - -- The menu's grid, horizon and sun were drawn black and unlit through the - intro, which kept the lights off them but not the blast's glow behind - them: a black line across a flash is a line, and the whole grid showed - through the explosion as its outline. They are not drawn at all until - the grid comes up out of the dark. The recording of the backdrop is - re-made with it. - - A PNG with 16-bit samples and no alpha channel (crate.png in the test scripts is one) drew with coloured fringes: SDL_image put libpng's big-endian 48-bit pixels into a surface the machine reads @@ -1512,7 +1613,7 @@ Fixes - musicPlay answers the handle it was given, as Hypseus's does. Its games keep that (sndHandle = musicPlay(music)) to stop the piece, and - three of them (Freddy, Starship Troopers, Sugar Rush) died on the map + three of them died on the map screen with "musicStop: Argument 1 must be a number" because Singe's answered nothing. musicStop takes a nil handle as none. @@ -1525,48 +1626,12 @@ Fixes gun dead with only "No mice detected" to explain it. Only --manymouse needs those devices now, and the message names the group. -- A game whose art is missing shows that it is missing, rather than taking - the engine down with it. An empty or wrong decorator path could open - something that is not a file, report a nonsense length, and throw - std::bad_alloc out of the GUI's texture loader -- a typo in a games.dat - ended the program. The loader now refuses an impossible size, and both - menu renderers fall back to Singe/missing.png, drawn at build time, so a - bad path reads as "MISSING art" instead of a hole in the page. - -- GUIs and 2D particles no longer blink. guiDraw and emitterDraw are - documented as lasting one frame, meaning one onOverlayUpdate -- but their - queues were emptied after every rendered frame, and the script's turn is - throttled to FRAME_TICK_MS while the display refreshes as often as it - can. With a 3D scene enabled it refreshes continuously, so the GUIs and - the 2D particles were drawn only on the frames the script happened to run - on: in the menu, a game list flickering over a grid that did not. The - queues are emptied when the script gets its turn instead. - -- The menu shows packed games properly. A .game is a database the engine - presents through the VFS, so lfs.attributes reports it as a directory, - not a file -- and the menu tested the mode before the extension, so every - container went down the loose-directory branch and its games.dat was - loaded with no container recorded. Every CABINET, MARQUEE and ATTRACT - path in it then named a file that exists only inside the database: a - library of .game files showed no art at all and died with "Unable to - open" on the first attract video. The extension is tested first now. - Games packed with earlier releases are correct and do not need repacking. - - A lone .m2v played with --framefile now finds the .ogg beside it, as a framefile's segments always have. A Daphne era elementary stream - carries no audio of its own, so lair.m2v played silently unless it was + carries no audio of its own, so such a .m2v played silently unless it was listed in a framefile or --altaudio was given. Any other container keeps using the audio inside it. -- A single-frame image saved as a 1-bit PNG (many overlay assets from - the 2.x era) loads again; spriteLoad reported "Blit combination not - supported" on it. Images with 16 bits per channel or floating point - pixels (16-bit PNGs among them) load as sprites, animation frames and - particle textures; they ended the program. - -- lfs.dir(".") and other names for the root work inside a packed game; - they were refused as if the directory did not exist. - - --sindengun and --scalefactor are listed in --help and the manual; they were hidden because mouse positions ignored the Sinden border and the overscan shrink. Positions now map into the picture actually @@ -1635,96 +1700,15 @@ Fixes copy differs from the running build. Upgrading the binary used to keep the old Framework.singe and Menu.singe forever. -- Lua reads a packed game where it lies. Its file handle is a C FILE * and - a database row is not a file, so anything a script opened by name inside - a .game was written out to data//cache first and the copy opened - instead -- a second copy of the game on disk, made a file at a time. - Everything else in the engine had long since stopped needing that: the - sprite loader, the decoders and the GUI's file interface all read a row - as a stream. Only Lua could not, because the io library is not ours. - It is patched now rather than worked around: io.open, io.lines and - io.input hand back a handle of Singe's own for a name that lives only in - the database, and it reads through the same stream everything else uses. - It answers to every method and format Lua's does, io.type calls it a - file, io.read and io.close take it, and it closes itself on collection or - on leaving a variable's scope; writing to it is an error, which - is what a packed asset is. testScripts/packedIo holds all of that to - Lua's own behaviour. With the last reader of the cache gone, the cache - itself is gone: vfsFilePath answers with a path only when there is a - real file, and a packed name being read is asked for as a stream. - -- The menu no longer unpacks a library's artwork to look at it. It asked - whether each picture was there by opening it, and io.open has to hand Lua - a file the C library can read, so every cabinet and marquee in a packed - library was written out to data//cache first -- none of which was - ever opened that way: spriteLoad and the document's texture loader both - read straight out of the database. The question is asked of the index - now, through lfs.attributes, which opens nothing. The cache remains for - what it is for: a game's own script opening its own files by name. - -- A packed game writes to one data directory. Everything that named a file - inside a .game by a path that passes through it -- a script opening one - of its own videos, which is how the menu loads an attract clip -- got a - data directory named after the container including the extension, while - the container itself, and everything else the same game wrote, used the - name without it. A library of packed games therefore grew two data - directories each: the video indexes in one and the saves, the overlay - and the cache in the other. Any component naming a database is reduced - to its stem now, so both land in the same place. An installation that - has both may delete the ".game" one; what was in it is rebuilt. - -- Both menus scroll their details. When a games.dat entry has more to say - than fits, the text walks down by itself a line at a time, rests, and - walks back up to the start rather than jumping there: up and down belong - to the library, and a cabinet has no key to spare for scrolling a - paragraph. The document renderer's description box had a scroll bar the - style sheet gave it and nothing to drive it with; the overlay renderer - clipped and left it at that. Page up and page down override the walk for - whoever does have a keyboard, and it carries on from where they leave it. - Both menus say so along the bottom. - -- The overlay menu -- the one a machine with no GPU gets -- shows what a - games.dat says about a game. Its details had the attract video's column - and whatever height was left under it, which at 720x480 was four lines, - so the description used most of them and every fact about the game fell - off the bottom. They run the full width now, under the artwork, with the - short fields grouped onto shared lines the way the document renderer - groups them and the description last rather than first. Cabinet and - marquee artwork is scaled to fit its region as well: spriteDraw blits at - the picture's own size, so art larger than the space allowed for it used - to be drawn straight over whatever was beside it. - -- The overlay menu says which key does what, along the bottom, as the - document menu always has. There was no way to learn that up and down - move one game and left and right move a page except to be told. - - Left and right move a third of the library, or a screenful, whichever is smaller. A page in a library of nine games was ten, so every press landed on the first game or the last and nothing between them could be reached that way at all. -- The menu's Start button is gone. Nothing but a mouse could ever reach it - -- the document takes no keyboard focus, and a cabinet has no pointer -- - and the line beside it already says which button starts a game. Clicking - a game's row still starts it. - -- The menu background video carries a keyframe every half second, and the - first frame of its looping section is one of them, so the menu's jumps - into and around it are instant. It has no audio track and is compressed - harder than the clips it replaced -- flat colour with hard edges is easy - to encode -- so what was 26 MB of source clips and 1.3 MB inside the - binary is now 0.9 MB of picture and 1.1 MB of sound. - - Framefiles whose first line is a relative directory produced garbage video paths (the directory string was freed before use); a lone "." now means the framefile's own directory. -- A crash prints a backtrace to the console (Linux and macOS) so a report - can say where it happened; run with --program and send trace.txt too. - -- Mouse positions are delivered in the video's coordinates again (SDL3 - stopped scaling them for us), so crosshairs follow the pointer. - - os.clock() in scripts now returns wall time rather than processor time. Nearly every existing game times its input debounces and prompts with it; once the GPU decodes the video the engine mostly sleeps, and those @@ -1736,10 +1720,6 @@ Fixes press in the script that follows. Confirming Exit in a game with button 1 used to relaunch it from the menu. -- Audio ran late by the device queue depth on most Linux audio servers: - the clock assumed one mixer buffer of latency where PulseAudio and - PipeWire hold several. See the audio sync entry above. - - Windows builds now carry the icon and version resource, with the version number encoded correctly. @@ -1772,167 +1752,23 @@ Fixes discGetFrame reports, so a skip in any segment after the first lands where it should. -- Every collision and trigger event of a frame reaches onCollision and - onTrigger; only the first 64 used to, the rest were dropped. - onTrigger fires once per body and trigger pair even when a mesh or - compound shape touches in several places. playerJump is granted - whenever the player is on the ground. - -- playerSetEnabled(node, false) removes the player's body from the - world and reports it leaving every trigger it stood in; enabling puts - it back. navPath ends a partial path at the last reachable point. - navLoad rejects data that is not a Detour mesh, or a file shorter - than the mesh its header describes ("the navigation mesh file is - truncated or damaged"). The world holds at most 4096 bodies; bodyNew - raises an error when it is full. - -- A body, player, ragdoll, soft body or vehicle whose node was deleted - is released at the start of the next frame, disabled, inactive, static - or paused or not; a disabled player or an inactive ragdoll on a deleted - node kept its slot (16 players, 8 ragdolls) forever. playerExists, - softExists, ragdollExists, vehicleExists and every setter answer false - for a node handle that was deleted and reused instead of driving the - previous owner's record. bodySetTrigger(node, false) no longer - swallows the leave of a trigger the node is standing in. - physicsSetDebug's mask is cleared by a reload. - -- Sprite nodes are no longer culled early at the edge of the view, and - shadow fitting sees their real size. Soft particles fade correctly in - a scene with no meshes instead of against stale depth. Point-light - shadows update when a caster's mesh is rewritten (soft bodies), given - morph targets, or swapped with nodeSetMesh at an unchanged transform; - the six cached faces could freeze in the old shape. Cascaded sun - shadows appear in render-to-texture views (viewNew) instead of - vanishing; a view takes the finest cascade that covers each point. - Debug lines no longer pile up while the scene layer is enabled but has - no target. - -- modelLoad refuses a skin with more than 128 joints or a skinned mesh - naming a joint numbered 128 or more (the shader used to read past the - matrices); a joint beyond the skins that use the mesh is traced and the - load goes on, so a mesh shared by skins of different sizes loads. A - texture that cannot be decoded, or a missing KTX2 image, is traced and - the load goes on without it instead of leaving modelLastError set. - KHR_materials_emissive_strength is applied. Sparse accessors load, - animation keyframes included, so Blender shape keys are no longer - silently zero; a sampler that cannot be read is traced and skipped, - and a rotation channel with a malformed output no longer produces NaN - transforms. Alpha MASK materials, textures on TEXCOORD_1 and up, and - KHR_texture_transform are traced as unsupported. - -- After a script reload, or any stall longer than a tenth of a second, - model animations advance by at most a tenth of a second instead of the - whole gap; a non-looping clip used to be finished before its first - frame. A 3D emitter whose node is deleted is removed even when the - handle is reused by a later nodeNew; it used to jump to the new node. - - Video index files are named -.index, the hash from the video's full name, so two videos with one base name in a game stop rebuilding each other's index. Old .index files are ignored and can be deleted. -- The packer reports every forbidden file in a directory rather than - stopping at the first. --unpack refuses a database whose stored names - would land outside the target directory. Launching a .game database - from the command line no longer crashes. - - --stretch hands the script mouse positions in the game's coordinates at any window size. --scalefactor with --sindengun shrinks the picture inside the Sinden border instead of being cancelled by it. A negative --audio track number is ignored. With no --datadir the controls.cfg beside the script is read once, not twice. -- A reload (F5, --reload, singeReload) ignores keys and buttons held - across it until they are released, resets the effect channels and their - pending onSoundCompleted queue, the held switches and the 2D particle - textures, restores the effects volume, mouse mode, mouse enabled state - and mouse capture, parks the disc on frame 1 paused, and no longer - calls into the closed Lua state when it happens while paused, so the - new script starts exactly as a fresh launch does. Under --reload a - script that fails to compile stays watched, so saving the fix triggers - the reload. - - Quitting with a stick past its dead zone no longer crashes; the release reaches onInputReleased after onShutdown, before the state closes. Plugging in a gamepad no longer resets the positions mouseGetPosition reports, and a controls.cfg mapping that lists the same code twice fires its switch once. -- materialSetVideo(material) on a framefile disc keeps showing the disc - after a segment change. - -- In a packed game the controls.cfg at the root of the data directory - loads again, io.open(".") returns nil with a message instead of a - handle whose first read fails, and a directory listing where a loose - file and a packed one differ only in case shows the loose spelling. - -- Error messages: an invalid view, navigation mesh or navigation agent - handle reports "No N." like every other handle; navAgentNew, - vehicleSetGears and viewNew state their limits from the engine's - constants; fontLoad and soundLoad of a missing file say "Unable to open - NAME" instead of the library's "invalid parameter"; navDraw reports its - argument count, and running out of memory in it is an error rather than - a silent blank; scriptExecute and scriptPush that cannot create the new - data directory abort the script with a message instead of exiting. - materialNew, sceneGetSize and sceneGetStats no longer reject extra - arguments. - -- A review of the week's work (the io patch, the scheduler, Forge, the - menu, the GLES backend, saves and the GUI) fixed what it found: - - Lua's io on a packed asset: f:lines and io.lines with more than one - format handed back only the last value read a step; a later format - that comes up empty now returns what came before it with nil in its - place, as Lua does, and only an empty first read ends the loop. - read("a") reads in blocks rather than a byte at a time, read(0) is the - end-of-file test it is in Lua, and a NUL byte is no longer taken for - part of a number. testScripts/packedIo covers all of it. - - Timer and tween handles carry a generation: a handle kept past its - timer's end no longer cancels, or answers for, whatever timer was - given the same slot afterwards. - - Forge: a rule's keyHeld condition never fired from a real keyboard - (it compared a key name against the scancodes the engine delivers); - an entity whose look kind is unknown is drawn as a box with a message - instead of ending the game; and the compiler loads the runtime from - Forge/, where it lives, rather than a Singe/Author.singe that was never - shipped. The games.dat Forge writes no longer carries a DATA key the - engine does not read, and the manual's example loses it too. - - The menu: a games.dat entry with no ATTRACT clip no longer stops the - engine on either renderer, quitting with no games listed no longer - fails in onShutdown, and a directory entry lfs cannot describe is - skipped rather than indexed. - - The GLES backend freed nothing when its framebuffer cache filled and - was reset; the framebuffer objects go now. A save is written with a - single atomic rename everywhere but Windows, so the old save stays - whole until the new one is in place. A GUI texture decoded with a - padded pitch is packed row by row instead of uploaded with its padding - read as pixels. The menu's backdrop stops rewriting its grid colours - every frame once the grid is lit. - -- navAgentNew placed an agent from the node's world position as of the - last drawn frame, so an agent made before the first frame -- or in a - game that never draws the scene, which is what a 2D room walking a - navigation mesh is -- started at the origin, off the mesh, and never - moved. The transforms are refreshed first now, as navAddNode already - did. - - Net.singe verified a connection against Linux's CA list by path, so - without a pin every HTTPS request failed on Windows, macOS and the - handhelds. The engine now extracts Mozilla's CA list as - Singe/cacerts.pem with the other support files and Net.singe uses it - wherever the platform's own list is missing. A chunked response -- - what an HTTP/1.1 server sends when it does not know the length -- is - unwrapped as it arrives rather than handed to the caller with the chunk - sizes still in it. And a mistyped value in a table given to scriptPush - or scriptExecute is reported like any other bad API argument -- the - script line and the call named -- rather than as a bare exit message. - - - SINGE 2.10 ========== @@ -1942,27 +1778,6 @@ SINGE 2.10 New Features ------------ -- Texture samplers now walk the whole mipmap chain. SDL hands max_lod - straight to Vulkan's maxLod and Direct3D 12's MaxLOD, and the scene - and GUI left it at the zero a memset gives, which clamps every lookup - to mip level 0. Mipmapping was therefore off on every backend: - distant surfaces aliased, the 8x anisotropy did far less than it - looked, and the sky cube could not pick a level by roughness. - -- An OpenGL ES 3.1 backend for the 3D scene and the GUI. SDL_GPU has - Vulkan, Direct3D 12 and Metal and no OpenGL of any kind, which left the - cheap Linux handhelds and the Raspberry Pi with 2D only: their Mali and - VideoCore parts have a mature GLES driver and either no Vulkan or an - immature one. The subset of SDL_GPU the scene and the GUI use is now - behind one table of function pointers with two implementations, so both - files stay the single description of what Singe draws, and the backend is - chosen at run time -- one aarch64 binary serves a board with Vulkan and one - without. The ES shaders come from the same HLSL through SPIRV-Cross, and - the GL entry points are loaded through SDL rather than linked, so a machine - with no GLES driver simply keeps its 2D renderer. The Raspberry Pi 4 - remains the minimum for 3D: the Pi 3 has only OpenGL ES 2.0, which cannot - express the scene's skinning or morph targets. - - spriteDraw() now has two more forms. In addition to being able to draw regular sprites and stretched sprites it can now draw both using the sprite's center as the anchor instead of the upper left. This is highly @@ -2197,5 +2012,5 @@ Notes ----- - Original add-on for Daphne. -- Shipped with the ActionMax "emulator" and games. +- Shipped with an "emulator" for a VHS game system, and its games. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ce52b3e4..b264afa98 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -172,6 +172,10 @@ function(singeEmbedLua path prefix) set(EMBEDDED_HEADERS ${EMBEDDED_HEADERS} PARENT_SCOPE) endfunction() +# assets/ holds only what is embedded in Singe. Sources those files are made from (the traced +# artwork, OBJ and STL models, the sound samples) and anything else not embedded live in media/. +# Forge, which ships on its own, lives in forge/. + # Images singeEmbedImage(font) # The icon is drawn large; the window gets a 128 pixel copy and the Windows .ico the usual sizes, @@ -226,7 +230,7 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "") -# Forge, the authoring tool, is built from assets/Forge but is NOT embedded: it is distributed on +# Forge, the authoring tool, is built from forge but is NOT embedded: it is distributed on # its own, not with the engine. Nothing of it -- not the editor, not the compiler, not the runtime # a game it builds loads -- belongs inside Singe. singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "") @@ -326,13 +330,13 @@ if(LUA_INTERPRETER) add_custom_command( OUTPUT ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc COMMAND ${CMAKE_COMMAND} -E chdir ${CMAKE_SOURCE_DIR} ${LUA_INTERPRETER} util/forgeVocabulary.lua > ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc - DEPENDS assets/Forge/Author.singe util/forgeVocabulary.lua + DEPENDS forge/Author.singe util/forgeVocabulary.lua COMMENT "Writing the Forge vocabulary" ) endif() # The book embeds the tutorials' pictures and includes their shipped descriptions, so any of # those changing renders it again; the globs are re-run on every build. -file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/images/tutorials/* ${CMAKE_SOURCE_DIR}/assets/Forge/tutorials/*.game) +file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/images/tutorials/* ${CMAKE_SOURCE_DIR}/forge/tutorials/*.game) add_custom_command( OUTPUT ${MANUAL_DIR}/Forge.pdf COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR} @@ -840,14 +844,22 @@ if(NOT SINGE_SHADERCROSS) message(FATAL_ERROR "SDL_shadercross is needed to compile the scene shaders; build through the superbuild (cmake --preset ...) or set SINGE_SHADERCROSS.") endif() endif() -# SPIRV-Cross turns the same SPIR-V into GLSL ES for the GLES backend (PLAN.md section 56). Unlike -# shadercross this one is not fatal when missing: the header then carries null ES strings, the GLES -# backend declines to start, and every other platform builds exactly as before. -if(NOT SINGE_SPIRV_CROSS) - find_program(SINGE_SPIRV_CROSS spirv-cross) -endif() -if(NOT SINGE_SPIRV_CROSS) - message(WARNING "spirv-cross was not found; this build will have no OpenGL ES shaders and so no GLES backend.") +# SPIRV-Cross turns the same SPIR-V into GLSL ES for the GLES backend (PLAN.md section 56). The +# superbuild builds it beside shadercross and hands it over, so there it is fatal when missing: a +# warning let every release binary ship with null ES strings and no GLES backend, unnoticed. +# Configuring this project alone it is only a warning; the header then carries null ES strings, +# the GLES backend declines to start, and every other platform builds exactly as before. +if(SINGE_FROM_SUPERBUILD) + if((NOT SINGE_SPIRV_CROSS) OR (NOT EXISTS ${SINGE_SPIRV_CROSS})) + message(FATAL_ERROR "The superbuild did not supply spirv-cross (SINGE_SPIRV_CROSS='${SINGE_SPIRV_CROSS}'); without it there are no OpenGL ES shaders.") + endif() +else() + if(NOT SINGE_SPIRV_CROSS) + find_program(SINGE_SPIRV_CROSS spirv-cross) + endif() + if(NOT SINGE_SPIRV_CROSS) + message(WARNING "spirv-cross was not found; this build will have no OpenGL ES shaders and so no GLES backend.") + endif() endif() set(shaderHeader ${CMAKE_BINARY_DIR}/generated/shaders/sceneShaders.h) add_custom_command( diff --git a/INSTALL b/INSTALL index 006bd62bc..090db7e50 100644 --- a/INSTALL +++ b/INSTALL @@ -69,12 +69,12 @@ libibus-1.0-dev libxkbcommon-dev libx11-dev libxext-dev libxfixes-dev libxi-dev libxcursor-dev libxrandr-dev libxss-dev libxtst-dev libwayland-dev wayland-protocols libdecor-0-dev. -The artwork, the font and the menu video in assets/ are stored with Git -LFS. Clone with git-lfs present, or those files arrive as small text -pointers and the build embeds the pointers instead of the assets; a -checkout already made without it is repaired with "git lfs install && -git lfs pull". build-all.sh stops with an error rather than build a -binary around them. +The artwork, the font, the models, and the menu video in assets/ and +media/ are stored with Git LFS. Clone with git-lfs present, or those +files arrive as small text pointers and the build embeds the pointers +instead of the assets; a checkout already made without it is repaired +with "git lfs install && git lfs pull". build-all.sh stops with an +error rather than build a binary around them. The Linux release build and the Windows build use zig as the compiler: the build fetches a pinned zig release (version and SHA-256 in diff --git a/LICENSES b/LICENSES index c959b4288..3b78f9048 100644 --- a/LICENSES +++ b/LICENSES @@ -74,7 +74,7 @@ Sounds The menu's intro (assets/menuIntro.flac, written by util/makeMenuSound.py) is built on three recordings from SoundBible, each under the Creative Commons Attribution 3.0 licence -(https://creativecommons.org/licenses/by/3.0/) and kept as FLAC under assets/samples: +(https://creativecommons.org/licenses/by/3.0/) and kept as FLAC under media/samples: Explosion Ultra Bass Mark DiAngelo CC BY 3.0 https://soundbible.com/1807-Explosion-Ultra-Bass.html Large Fireball Mike Koenig CC BY 3.0 https://soundbible.com/1348-Large-Fireball.html diff --git a/assets/Backdrop.singe b/assets/Backdrop.singe index cf2b7b567..09c5e0db5 100644 --- a/assets/Backdrop.singe +++ b/assets/Backdrop.singe @@ -26,7 +26,8 @@ -- The menu's backdrop: the intro and the grid the menu sits on, as one scene. -- -- A charge goes off, the Singe dragon and name fly out of it toward the camera, the tagline fades --- in underneath, the dragon rears and breathes flame -- and then the grid comes up out of the dark +-- in underneath, the dragon rears and roars, then rears again and breathes flame -- and then the +-- grid comes up out of the dark -- beneath the logo while the smoke is still clearing, the logo lifts away, and what is left is the -- backdrop the menu sits on. One scene from the first frame to the last: no cut, and nothing -- loads at the join. @@ -107,16 +108,27 @@ local LOGO_Y = 0.15 -- Where the logo settles. It is the dragon that -- worth re-measuring if either model changes. -- The dragon carries no animation, but util/dragonModel.py builds it out of named parts -- head, --- neck, wingL, wingR and the rest -- and every one of them is its own node. They all sit at the +-- jaw, neck, wingL, wingR and the rest -- and every one of them is its own node. They all sit at the -- model's origin though, so turning a wing node would swing the wing round the dragon's middle -- rather than its shoulder. The joints are baked into the model now; coordinates below are the -- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z. -local MOUTH = { 3.55, 5.60, 0.00 } -- Where the flame leaves, in model coordinates. -local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see assets/DragonPivots.json. +local MOUTH = { 2.88, 5.46, 0.00 } -- Where the flame leaves, in model coordinates: in the gap + -- between the snout and the jaw once the jaw has dropped, back + -- from the nose so the fire comes out of the mouth, not the nose. +local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see media/DragonPivots.json. local FLAP_MAX = 34.0 -- Degrees a wing rises and falls. local FLAP_FAST = 13.0 -- Radians a second while it is flying out of the blast. local FLAP_SLOW = 3.4 -- and once it has settled. -local REAR_BACK = 3.05 -- Seconds: the head starts to rear. +local ROAR_BACK = 2.20 -- Seconds: the head starts to rear for the roar, which builds in + -- media/samples/dragonFireBreathAndRoar.flac from here, +local ROAR_TOP = 2.50 -- is fully back as the roar reaches its loudest, +local ROAR_HOLD = 2.85 -- held there until the roar falls away, + -- and down again by REAR_BACK, so the rear for the fire reads as a + -- second one rather than as the roar carrying on. +local ROAR_ANGLE = 24.0 -- Degrees the head tips back to roar. +local JAW_OPEN = -30.0 -- Degrees the lower jaw drops, for the roar and for the fire. +local NECK_SHARE = 0.35 -- How much of the head's pitch the neck takes as well. +local REAR_BACK = 3.05 -- Seconds: the head starts to rear for the fire. local REAR_TOP = 3.40 -- fully back. local REAR_STRIKE = 3.58 -- and thrown forward. local BREATH_START = 3.46 @@ -124,7 +136,7 @@ local BREATH_END = 4.35 local REAR_DONE = 4.90 -- Back to where it started. local REAR_ANGLE = 34.0 -- Degrees the head tips back. local STRIKE_ANGLE = -14.0 -- and forward through the strike. -local DRAGON_YAW = -32.0 -- The dragon alone is turned toward the camera, so the wingbeat is +local DRAGON_YAW = -148.0 -- The dragon faces left, turned toward the camera so the wingbeat is -- seen three quarters on rather than edge on; the name stays square -- to the camera because it has to be read. local TAGLINE = "SINGE Is Not a Game Emulator" @@ -157,7 +169,7 @@ sceneSetTonemap(TONEMAP_ACES) -- The dragon's parts carry their own joints now: util/objToGlb.py bakes them from --- assets/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without +-- media/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without -- anything being rigged here. This used to build them at run time from bounding boxes, which -- worked but left every future use of the model to re-derive the same thing. local function joint(root, name, parent) @@ -281,11 +293,14 @@ nodeSetScale(dragon, 0.9) nodeSetScale(logo, 0.5) nodeSetVisible(logo, false) --- The rig. The head hangs off the neck so that rearing the neck carries the head with it. +-- The rig. The head hangs off the neck so that rearing the neck carries the head with it, and +-- the jaw off the head for the same reason. Tip first: joint measures the parent where it sits, +-- so the jaw has to be hung on the head while the head is still at its own baked joint rather than +-- already moved to be relative to the neck. local wingLeft = joint(dragon, "wingL") local wingRite = joint(dragon, "wingR") local neck = joint(dragon, "neck") --- The head hangs off the neck so that rearing the neck carries the head with it. +local jaw = joint(dragon, "jaw", nodeFind("head", dragon)) local head = joint(dragon, "head", neck) -- The flame, on a node at the dragon's mouth so it follows the head round. @@ -534,10 +549,15 @@ function backdropFrame() nodeSetRotation(wingLeft, -flap, 0, 0) nodeSetRotation(wingRite, flap, 0, 0) - -- The head rears back, snaps forward, and settles; the flame goes with the strike. + -- The head rears to roar and comes down; then it rears again, snaps forward, and settles, + -- and the flame goes with the strike. local pitch = 0 - if t < REAR_TOP then + if t < ROAR_TOP then + pitch = ROAR_ANGLE * easeOut(span(t, ROAR_BACK, ROAR_TOP)) + elseif t < REAR_BACK then + pitch = ROAR_ANGLE * (1 - span(t, ROAR_HOLD, REAR_BACK)) + elseif t < REAR_TOP then pitch = REAR_ANGLE * span(t, REAR_BACK, REAR_TOP) elseif t < REAR_STRIKE then local k = span(t, REAR_TOP, REAR_STRIKE) @@ -547,11 +567,21 @@ function backdropFrame() pitch = STRIKE_ANGLE * (1 - easeOut(span(t, REAR_STRIKE, REAR_DONE))) end nodeSetRotation(head, 0, 0, pitch) - nodeSetRotation(neck, 0, 0, pitch * 0.35) + nodeSetRotation(neck, 0, 0, pitch * NECK_SHARE) + + -- The mouth opens as the head goes back for the roar and shuts as it comes down, then + -- opens again for the fire and stays open until the flame is spent. + local roarJaw = span(t, ROAR_BACK, ROAR_TOP) * (1 - span(t, ROAR_HOLD, REAR_BACK)) + local fireJaw = span(t, REAR_BACK, BREATH_START) * (1 - span(t, BREATH_END, REAR_DONE)) + local jawPitch = JAW_OPEN * math.max(roarJaw, fireJaw) + + nodeSetRotation(jaw, 0, 0, jawPitch) -- The flame leaves the mouth along the dragon's own forward, which the whole logo has been -- turning; a 3D emitter takes its direction in world axes, so it is turned here by hand. - local aim = math.rad(pitch) + -- The head rides on the neck, so its pitch in the world is its own plus the neck's, and the + -- flame follows the middle of the open mouth, half the jaw's drop below the head's line. + local aim = math.rad(pitch * (1 + NECK_SHARE) + jawPitch / 2) local spin = math.rad(yaw + DRAGON_YAW) emitterSetDirection(breath, math.cos(aim) * math.cos(spin), math.sin(aim), -math.cos(aim) * math.sin(spin)) diff --git a/assets/DragonModel.glb b/assets/DragonModel.glb index fdc59fa3b..2e006277a 100644 --- a/assets/DragonModel.glb +++ b/assets/DragonModel.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4133c9338754ec3e4e04a45d335b018f07b8f61a94c840622051108332561538 -size 131996 +oid sha256:5771565216665018104eed6c4d859962e59dc840ecd2b90ac64d7119a96f1311 +size 134240 diff --git a/assets/Menu.singe b/assets/Menu.singe index 46f48eb2a..55242a2f4 100644 --- a/assets/Menu.singe +++ b/assets/Menu.singe @@ -486,6 +486,9 @@ if GAME_COUNT == 0 then else overlaySetResolution(discGetWidth(), discGetHeight()) + -- No disc: the menu is laid out on the canvas, 720x480 unless --canvas says otherwise, and the + -- renderer that needs Singe/menuBackground.mkv loads it itself. + -- -- Three frames of Singe/menuBackground.mkv: where the menu may take the screen, where the -- grid section it loops begins, and its last frame. The first two are not the same moment -- -- the menu comes up while the music is still fading out behind it, and only then does the diff --git a/assets/MenuOverlay.singe b/assets/MenuOverlay.singe index fc1f50bcd..f20acf367 100644 --- a/assets/MenuOverlay.singe +++ b/assets/MenuOverlay.singe @@ -74,6 +74,7 @@ local hintFont = nil -- Smaller: the hints are a footnote, and at the -- of them ran off the end of a 720 pixel overlay. local titleFont = nil -- Larger: a tool's title on its bar. local introDone = false +local backdrop = nil -- Singe/menuBackground.mkv, played here rather than as the menu's disc local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0 local textX, textY, textW, textH = 0, 0, 0, 0 @@ -316,41 +317,52 @@ MENU_RENDER.finish = function() end end font, hintFont, titleFont = nil, nil, nil + if backdrop then + videoUnload(backdrop) + backdrop = nil + end end --- The backdrop is the disc: the engine intro, then the grid section played over and over. A +-- The backdrop is a recording: the engine intro, then the grid section played over and over. A -- machine with no GPU device has nothing to draw a grid with, so this renderer plays the one in --- Singe/menuBackground.mkv; Singe/MenuDocument.singe draws its own instead. +-- Singe/menuBackground.mkv; Singe/MenuDocument.singe draws its own instead. It is loaded here +-- rather than handed to the menu as its disc, so the renderer that draws its own never opens it. MENU_RENDER.backdropBegin = function(showIntro) introDone = not showIntro - discPlay() + backdrop = videoLoad("Singe/menuBackground.mkv") + videoPlay(backdrop) if not showIntro then - discSkipToFrame(DISC_GRID_START) + videoSeek(backdrop, DISC_GRID_START) end end MENU_RENDER.backdrop = function() - -- Loop the grid. A machine too slow to show every disc frame can step over the last one, after - -- which the engine starts the disc again from the top; once the intro has played, that is a loop. - if discGetFrame() >= DISC_LAST_FRAME or (introDone and discGetFrame() < DISC_GRID_START) then - discSkipToFrame(DISC_GRID_START) + -- Loop the grid. A machine too slow to show every frame can step over the last one, after which + -- the player starts the video again from the top; once the intro has played, that is a loop. + local frame = videoGetFrame(backdrop) + + if frame >= DISC_LAST_FRAME or (introDone and frame < DISC_GRID_START) then + videoSeek(backdrop, DISC_GRID_START) end - if discGetFrame() >= DISC_GRID_START then + if frame >= DISC_GRID_START then introDone = true end - -- Transparent, so the disc behind the overlay is what shows. + -- The recording fills the overlay, and everything else is drawn over it. Cleared see-through + -- rather than black, which looks the same under a picture that covers everything, so the engine + -- can have the GPU draw the recording instead of copying every frame of it into the overlay. colorBackground(0, 0, 0, 0) overlayClear() + videoDraw(backdrop, 0, 0, overlayGetWidth() - 1, overlayGetHeight() - 1) end MENU_RENDER.backdropReady = function() -- The menu comes up where the drawn backdrop's logo leaves, which is before the picture starts -- repeating: the frames between the two are the grid running on while the music fades out. - return discGetFrame() >= DISC_MENU_FRAME + return videoGetFrame(backdrop) >= DISC_MENU_FRAME end diff --git a/assets/Tools.singe b/assets/Tools.singe index 808a42529..14a6a7744 100644 --- a/assets/Tools.singe +++ b/assets/Tools.singe @@ -786,11 +786,17 @@ TOOLS[#TOOLS + 1] = { discResults = {} discPending = nil discWorst = 0 - discShow("Button 1 runs the test.") + -- The menu runs without a disc of its own, so there is only something to time when it was + -- started with one (-v or a framefile on its command line). + if discGetFrameCount() == 0 then + discShow("There is no disc to test: start the menu with -v to time one.") + else + discShow("Button 1 runs the test.") + end end, input = function(what) - if what == SWITCH_BUTTON1 and not discPending then + if what == SWITCH_BUTTON1 and not discPending and (discGetFrameCount() > 0) then discResults = {} discWorst = 0 discPending = { step = 0, started = 0 } diff --git a/assets/controls.cfg b/assets/controls.cfg index 330ef42b4..fe586b674 100644 --- a/assets/controls.cfg +++ b/assets/controls.cfg @@ -44,7 +44,9 @@ DEAD_ZONES = { TRIGGER_THRESHOLD = 0 -- One table per switch, in the order the engine numbers them (SWITCH_UP = 0 ...). --- Each entry is a { name, value } pair from the SCANCODE, GAMEPAD_N, or MOUSE_N tables. +-- Each entry is a { name, value } pair from the SCANCODE, GAMEPAD_N, or MOUSE_N tables, or a +-- chord: a list of two to four of them held together, such as +-- { GAMEPAD_0.BUTTON_BACK, GAMEPAD_0.BUTTON_START }. Each keeps its own meaning as well. INPUT_UP = { SCANCODE.UP, SCANCODE.KP_8, GAMEPAD_0.AXIS_LEFT_Y_U, GAMEPAD_0.AXIS_RIGHT_Y_U, GAMEPAD_0.DPAD_UP } INPUT_LEFT = { SCANCODE.LEFT, SCANCODE.KP_4, GAMEPAD_0.AXIS_LEFT_X_L, GAMEPAD_0.AXIS_RIGHT_X_L, GAMEPAD_0.DPAD_LEFT } INPUT_DOWN = { SCANCODE.DOWN, SCANCODE.KP_2, GAMEPAD_0.AXIS_LEFT_Y_D, GAMEPAD_0.AXIS_RIGHT_Y_D, GAMEPAD_0.DPAD_DOWN } diff --git a/assets/menuBackground.mkv b/assets/menuBackground.mkv index 3044f6648..fdb0cb929 100644 --- a/assets/menuBackground.mkv +++ b/assets/menuBackground.mkv @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c943df76b0b52756c56528c83c028cb62eece8934aa2fa592a05bb791c3f1c30 -size 928159 +oid sha256:05ca8170de08670f4c8d0a5d2e05b861b0f024efa9b3022df6545ff7ea0f525d +size 938199 diff --git a/assets/settings.cfg b/assets/settings.cfg index dbaab53c1..24d279af0 100644 --- a/assets/settings.cfg +++ b/assets/settings.cfg @@ -28,10 +28,10 @@ -- from the same four places it reads controls.cfg, in this order, and a later -- one wins key by key: -- --- 1. the directory Singe was started in --- 2. the directory above the game's data directory --- 3. the game's data directory --- 4. the game's own script directory +-- 1. the game's own script directory: what the game ships +-- 2. the directory Singe was started in: the machine's own +-- 3. the directory above the game's data directory +-- 4. the game's data directory: yours, for that one game -- -- Every key is the long name of a command line option without the leading -- dashes, so there is no second vocabulary to learn, and the command line @@ -57,6 +57,7 @@ -- fullscreen_window = false -- borderless window covering the desktop -- nologos = false -- skip the splash screens -- canvas = "720x480" -- world size for a game with no disc +-- scenequality = "high" -- 3D detail: high, or low for a small GPU (half resolution, no antialiasing, no bloom) -- Where the picture sits ------------------------------------------------- @@ -114,6 +115,7 @@ -- idleexit = 0 -- quit after this many seconds with no input; 0 never does -- no_update_check = false -- never look for a newer Singe from the menu (Engine Update still works by hand) +-- quitchord = "back+start" -- first gamepad buttons held together that quit whatever a game maps -- fvalue = 0 -- one number handed to the game, which reads it with getFValue() @@ -121,9 +123,11 @@ -- reload = false -- rerun the game when one of its loose script files changes -- program = false -- trace engine activity to trace.txt +-- perfsync = false -- with program, show the GPU's time in each stage of the perf lines; slows everything -- trace = false -- trace every Lua call to trace.txt -- showcalculated = false -- print the frame ranges of every framefile segment -- softwarevideo = false -- decode video in software even when the machine can do it in hardware +-- zerocopy = true -- hand hardware decoded frames to the GPU without copying them, where it can -- deinterlace = "auto" -- what an interlaced picture gets: off, auto or on. A laserdisc held -- interlaced fields, and a rip that kept them combs on a progressive -- display; auto touches only the frames the file marks interlaced diff --git a/build-all.sh b/build-all.sh index 082dfc07e..15ff6a0a8 100755 --- a/build-all.sh +++ b/build-all.sh @@ -117,7 +117,7 @@ function buildAll() { } -# assets/ holds the artwork, the font and the menu video in Git LFS. A clone made without git-lfs +# assets/ and media/ hold the artwork, the font, the models, and the menu video in Git LFS. A clone made without git-lfs # leaves pointer stubs -- a hundred and thirty bytes of text where a video should be -- and the # resource generators embed them without complaining, so the failure only shows up at run time as a # menu with no background. One sentinel is enough to tell the two apart. @@ -213,7 +213,7 @@ sudo apt-get install -y "${G_HOSTPACKAGES[@]}" if ! checkoutHydrated; then - echo "error: assets/ holds git-lfs pointer files, not the real artwork, font and video." + echo "error: assets/ and media/ hold git-lfs pointer files, not the real artwork, font and video." echo " The build would embed those stubs and produce a broken binary." echo " Fix the checkout with: git lfs install && git lfs pull" exit 1 diff --git a/cmake/Superbuild.cmake b/cmake/Superbuild.cmake index 82abf2c34..ac098c521 100644 --- a/cmake/Superbuild.cmake +++ b/cmake/Superbuild.cmake @@ -450,7 +450,7 @@ ExternalProject_Add(singe SOURCE_DIR ${CMAKE_SOURCE_DIR} BINARY_DIR ${singeBinary} DEPENDS zlib zstd SDL3 SDL3_image SDL3_mixer SDL3_ttf openssl ffmpeg Jolt recastnavigation RmlUi shadercross - CONFIGURE_COMMAND ${SB_ENV} ${CMAKE_COMMAND} -S ${CMAKE_SOURCE_DIR} -B ${singeBinary} ${SB_CMAKE_ARGS} -DSINGE_SHADERCROSS=${SINGE_SHADERCROSS} -DSINGE_SUPERBUILD=OFF -DKANGAROO_OS=${KANGAROO_OS} -DKANGAROO_ARCH=${KANGAROO_ARCH} -DSINGE_TREE=${SINGE_TREE} + CONFIGURE_COMMAND ${SB_ENV} ${CMAKE_COMMAND} -S ${CMAKE_SOURCE_DIR} -B ${singeBinary} ${SB_CMAKE_ARGS} -DSINGE_SHADERCROSS=${SINGE_SHADERCROSS} -DSINGE_SPIRV_CROSS=${SINGE_SPIRV_CROSS} -DSINGE_FROM_SUPERBUILD=ON -DSINGE_SUPERBUILD=OFF -DKANGAROO_OS=${KANGAROO_OS} -DKANGAROO_ARCH=${KANGAROO_ARCH} -DSINGE_TREE=${SINGE_TREE} BUILD_COMMAND ${SB_ENV} ${CMAKE_COMMAND} --build ${singeBinary} --parallel ${SB_JOBS} INSTALL_COMMAND ${CMAKE_COMMAND} -E copy ${singeBinary}/${singeBinaryBase}${CMAKE_EXECUTABLE_SUFFIX} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} BUILD_ALWAYS ON @@ -458,15 +458,17 @@ ExternalProject_Add(singe # Forge, packed into .builddir beside the binary so it can be copied out and tested. It is NOT # part of the engine and nothing of it is embedded; this only puts the distributable where the -# binaries already are. Only a build that produces a runnable binary can pack it, since packing is -# the engine's own --pack, so cross builds skip it and the host build does the work. The manual, +# binaries already are. Only a build that produces a binary this machine can run can pack it, since +# packing is the engine's own --pack: the architecture has to match as well as the system, or the +# shell is handed an ARM executable and answers "Syntax error". Every other build skips it and the +# host build does the work. The manual, # rendered by the engine's own build into .builddir, goes in beside the scripts: Forge copies it # out to its data directory on its first run. -if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")) - # Everything under assets/Forge -- the scripts, the kit, the tutorials -- and re-globbed on +if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") AND (KANGAROO_ARCH STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR)) + # Everything under forge/ -- the scripts, the kit, the tutorials -- and re-globbed on # every build, so a file added or changed anywhere in it repacks Forge.game without anyone # having to remember. - file(GLOB_RECURSE forgeSources CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/assets/Forge/*) + file(GLOB_RECURSE forgeSources CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/forge/*) set(forgePack ${CMAKE_SOURCE_DIR}/.builddir/Forge.game) set(forgeManual ${CMAKE_SOURCE_DIR}/.builddir/Forge.pdf) set(forgeStage ${SB_PREFIX}/forgepack) @@ -474,7 +476,7 @@ if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")) OUTPUT ${forgePack} # Packing refuses to overwrite, and a stale pack is worse than none. COMMAND ${CMAKE_COMMAND} -E rm -rf ${forgePack} ${forgeStage} - COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets/Forge ${forgeStage}/Forge + COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/forge ${forgeStage}/Forge COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf # The samples that need no art beyond what the engine ships, so the chooser has something to # open on a fresh install. They come from the test set, which is the one copy of them. diff --git a/cmake/shaderHeader.cmake b/cmake/shaderHeader.cmake index 86ad9b9e4..8cce84c81 100644 --- a/cmake/shaderHeader.cmake +++ b/cmake/shaderHeader.cmake @@ -25,7 +25,9 @@ set(entries ${ENTRIES}) set(formats SPIRV DXIL MSL) # GLSL ES 3.10 for the GLES backend. Three switches matter and each is load bearing: -# --es --version 310 ES 3.1, the floor the scene needs; its storage buffers are ES 3.1 only. +# --es --version 310 ES 3.1, the floor the scene needs. The vertex stage's storage buffers become texture +# buffers in this form (SINGE_ESSL below), since ES lets a vertex shader have no +# storage blocks and Mali drivers give it none; renderGles.c adjusts the extension. # --combined-samplers-inherit-bindings # HLSL's separate Texture2D and SamplerState have no ES equivalent, so SPIRV-Cross folds # each pair into one sampler and keeps the HLSL t-register as the binding, which is the @@ -71,12 +73,18 @@ foreach(entry IN LISTS entries) string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," bytes "${hex}") string(APPEND header "static const unsigned char _${name}${format}[] = {\n\t${bytes}\n};\n\n") endforeach() - # The ES form, cross compiled from the SPIR-V above. Emitted as a C string rather than bytes because that is what - # glShaderSource takes; the escaping is only quotes and backslashes, neither of which GLSL ES has a use for. + # The ES form, cross compiled from its own SPIR-V: the same source with SINGE_ESSL defined, which reads the + # vertex stage's storage buffers as texture buffers. Emitted as a C string rather than bytes because that is + # what glShaderSource takes; the escaping is only quotes and backslashes, neither of which GLSL ES has a use for. set(esslSymbol "NULL") if(SPIRVCROSS) + set(esslSpirv ${work}/${name}.essl.spirv) + execute_process(COMMAND ${SHADERCROSS} ${SOURCE} -s HLSL -d SPIRV -t ${stage} -e ${name} -I ${includeDir} -DSINGE_ESSL -o ${esslSpirv} RESULT_VARIABLE code OUTPUT_VARIABLE out ERROR_VARIABLE err) + if(NOT code EQUAL 0) + message(FATAL_ERROR "Shader ${name} (SPIR-V for GLSL ES) failed to compile:\n${out}\n${err}") + endif() set(essl ${work}/${name}.essl) - execute_process(COMMAND ${SPIRVCROSS} ${esslFlags} ${work}/${name}.spirv --output ${essl} RESULT_VARIABLE code OUTPUT_VARIABLE out ERROR_VARIABLE err) + execute_process(COMMAND ${SPIRVCROSS} ${esslFlags} ${esslSpirv} --output ${essl} RESULT_VARIABLE code OUTPUT_VARIABLE out ERROR_VARIABLE err) if(NOT code EQUAL 0) message(FATAL_ERROR "Shader ${name} (GLSL ES) failed to cross compile:\n${out}\n${err}") endif() diff --git a/docs/Forge.adoc b/docs/Forge.adoc index 3803226a0..1c0cae8bb 100644 --- a/docs/Forge.adoc +++ b/docs/Forge.adoc @@ -130,7 +130,7 @@ in the chooser is this file. [source,lua] ---- -include::../assets/Forge/tutorials/01-tenMinutes.forge[] +include::../forge/tutorials/01-tenMinutes.forge[] ---- === Tutorial 2: A Platformer @@ -231,7 +231,7 @@ is the three types, their entities, the three rules, and the second room. [source,lua] ---- -include::../assets/Forge/tutorials/02-platformer.forge[] +include::../forge/tutorials/02-platformer.forge[] ---- === Tutorial 3: Sprites and Sound @@ -311,7 +311,7 @@ states are in <<_instances_vars_and_states>> and [source,lua] ---- -include::../assets/Forge/tutorials/03-sprites.forge[] +include::../forge/tutorials/03-sprites.forge[] ---- === Tutorial 4: A Shoot-Em-Up @@ -412,7 +412,7 @@ runs once per instance of a type, with that instance as `self`, and a [source,lua] ---- -include::../assets/Forge/tutorials/04-shmup.forge[] +include::../forge/tutorials/04-shmup.forge[] ---- === Tutorial 5: A Quick-Time Event @@ -475,7 +475,7 @@ image::tutorials/05-rules.png[The rules, 600] **What happened.** A `disc` layer plays a video underneath everything, and the rules read its *frame*: `discBetween` is true while the disc is inside a window, `frameReached` fires as it passes a frame, and `discTo` sends it -elsewhere -- the whole of a Dragon's Lair is windows and jumps. `once` +elsewhere -- the whole of a branching laserdisc game is windows and jumps. `once` keeps a rule from firing every frame the window is open, and `keyPressed` is the frame a key goes down, where `keyHeld` is every frame it is down. `setVar` and `test` on a var of your own are how a rule remembers what @@ -493,14 +493,14 @@ released game, `games.dat` names the video, as it does for any Singe game. [source,lua] ---- -include::../assets/Forge/tutorials/05-qte.forge[] +include::../forge/tutorials/05-qte.forge[] ---- === Tutorial 6: A Light Gun Game image::tutorials/06-playing.png[Draw, 600] -**What you will make.** Mad Dog McCree in miniature: the clip plays, a +**What you will make.** A light gun shooting gallery in miniature: the clip plays, a target rises, and the player shoots it with the mouse or a light gun. The hand that reaches in is not to be shot. Ammo runs out and is reloaded by a shot off the picture. @@ -579,7 +579,7 @@ with a light gun needs -- calibration, two guns, the recoil -- is in [source,lua] ---- -include::../assets/Forge/tutorials/06-gun.forge[] +include::../forge/tutorials/06-gun.forge[] ---- === Tutorial 7: A Point-and-Click Adventure @@ -706,7 +706,7 @@ off. All of it is in [source,lua] ---- -include::../assets/Forge/tutorials/07-adventure.forge[] +include::../forge/tutorials/07-adventure.forge[] ---- === Tutorial 8: Into 3D @@ -801,7 +801,7 @@ viewport in <<_the_3d_viewport>>. [source,lua] ---- -include::../assets/Forge/tutorials/08-scene.forge[] +include::../forge/tutorials/08-scene.forge[] ---- === Tutorial 9: A Rail Shooter @@ -920,7 +920,7 @@ in <<_games_in_the_scene>>; painted rooms in [source,lua] ---- -include::../assets/Forge/tutorials/09-rail.forge[] +include::../forge/tutorials/09-rail.forge[] ---- === Tutorial 10: Releasing Your Game @@ -1003,7 +1003,7 @@ is what a cabinet's coin door shows. Releasing is in [source,lua] ---- -include::../assets/Forge/tutorials/10-release.forge[] +include::../forge/tutorials/10-release.forge[] ---- === Recipes @@ -1065,7 +1065,7 @@ ones. *The board and the bezel* (`fmv.forge`). `bezel` round any game, `credit` on the coin switch, `restart` on start with credits, and `submitScore` at -the end; `branching` for a Dragon's Lair of windows, moves, and jumps. +the end; `branching` for a branching game of windows, moves, and jumps. *Anything else.* The `lua` action runs a line of Singe Lua in a rule, with `self`, `other`, and `event` in scope, and the engine's whole API with them: @@ -1309,7 +1309,7 @@ row of bullets or of hearts; the `pointer` behaviour keeps an instance on the player's pointer, a crosshair, and hides it when the engine says a real gun wants none drawn; `discPlay` and `discPause` let the disc go and hold it, a still behind a title; `pointerX(1)` and `pointerY(1)` in an -expression say where the pointer is. The ActionMax way of judging a shot +expression say where the pointer is. The light sensor way of judging a shot over video -- the picture under the gun agreeing with a sensor spot across two frames -- is a `lightSensor` behaviour those five games carry in a vocabulary of their own (see <>), not one of Forge's. @@ -1323,7 +1323,7 @@ game's script. The game's script and its `Script/addons.singe` run inside the behaviour, so a game keeps its own scoring and level order. The seventeen games of the test library and eleven of the Hypseus library are ported this way and play as their originals do (FORGE.md section 14), and -so is LINEA, the one game on the MazescaterFramework -- that framework +so is the one game on the MazescaterFramework -- that framework with compound moves (sequences of two or three inputs, circles from any start, mashes of a direction or a button, a direction with the second or third button), which the behaviour judges from tables of its own once the @@ -1343,17 +1343,16 @@ where the game carries one, a map between levels -- with the copies' drift (the later generation's level order, game type, and state numbers) gated by what the game's script declares, and what a title does without declaring it named in a `tweaks` table the converter writes. The -`timegal` behaviour plays the same author's Time Gal remake, a compact +`timegal` behaviour plays one of the same author's remakes, a compact loop of its own (checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, a level order by tiers), over the game's own -files in the same way; and the `sdq` behaviour plays Super Don Quixote, +files in the same way; and the `sdq` behaviour plays one game that is the one hybrid of map-mode data under the Karis framework's judging, with its difficulty select, level clear, and trophies. *A hand-written game.* A game that was a program of its own rather than -a framework's tables -- the American Laser Games titles, Mad Dog McCree -first among them and the typing edition of Mad Dog II the ninth, and -Platoon, a prototype in their shape -- is ported as a vocabulary of its own beside +a framework's tables -- a set of light gun titles, a typing edition of one +of them, and a prototype in their shape -- is ported as a vocabulary of its own beside its description (see <>): a behaviour that is the game's loop rewritten in Forge's terms, its screens and each level's flow, over the game's own @@ -1476,7 +1475,7 @@ examples; scenes 66 and 67 play them. === Arcade Plumbing: Branching Video, Lives, Credits, the Board -A `branches` track is Dragon's Lair: each branch is a window of disc frames, +A `branches` track is a branching laserdisc game: each branch is a window of disc frames, the `move` (a key) or `switch` it wants, and the frames the disc goes to on `success` and on `fail`; a type with `{ kind = "branching", track = "moves" }` watches it and raises `branchOpen` (with `event.move`), `branchTaken`, and @@ -1592,7 +1591,7 @@ sets `instance.hearsAll` and reads them in `on`. A word that turns out to be general belongs in the framework rather than beside one game. `lightSensor`, which judges a shot by the picture under the -gun, began as the five ActionMax games' own vocabulary and is now one of the +gun, began as five light gun games' own vocabulary and is now one of the behaviours above, with `pictureAt` beside it as a condition any rule can ask; those five name no vocabulary at all any more. The test scene's `bob` is the working example instead, and the rule to apply is the same one: a vocabulary @@ -1856,7 +1855,7 @@ drive it. | `K` | a key at the cursor; in a 3D room, a rail point where the camera stands | `J`, `L`, `Y`, `H`, `I`, `K` | in a 3D room: orbit, tilt, and zoom the editor camera | `U`, `R` | undo and redo, forty steps deep; a drag or a typed form is one step, and a change by hand ends the redo history -| `S`, `B`, `P`, `X` | save, build, play, release +| `S`, `B`, `P`, `X`, `Shift+P` | save, build, play, release, publish |=== From a script the same work is `forgeEntityAdd`, `forgeEntityDuplicate`, @@ -1886,7 +1885,7 @@ for every named file there first, so the release runs wherever it is put. `--pack` turns that directory into a `.game` like any other, and it runs on a machine that has never had Forge on it. -`P` goes one step further: it exports, packs, and sends the package to the +`Shift+P` goes one step further: it exports, packs, and sends the package to the master service for review, in one press. It is not in the catalogue until it has been reviewed, and Forge says which version number it was given. Press again after a change and a later version goes up on its own, without taking an diff --git a/docs/ForgeVocabulary.adoc b/docs/ForgeVocabulary.adoc index 33ae23941..be88140b8 100644 --- a/docs/ForgeVocabulary.adoc +++ b/docs/ForgeVocabulary.adoc @@ -1,4 +1,4 @@ -// Generated by util/forgeVocabulary.lua from assets/Forge/Author.singe. Do not edit. +// Generated by util/forgeVocabulary.lua from forge/Author.singe. Do not edit. === Layers @@ -107,7 +107,7 @@ | `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere) | `branching` -| Dragon's Lair: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed. +| A branching laserdisc game: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed. | `track` (track) | `camera` @@ -191,7 +191,7 @@ | -- | `sdq` -| Plays Super Don Quixote: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the Karis 3.31c framework judges, with its difficulty select, level clear, finish, and trophies. Written by util/forgePortKaris.lua from the game's script. +| Plays one hybrid map-mode game: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the Karis 3.31c framework judges, with its difficulty select, level clear, finish, and trophies. Written by util/forgePortKaris.lua from the game's script. | -- | `seek` @@ -231,7 +231,7 @@ | `force` (number), `turn` (number) | `timegal` -| Plays RDG2010's Time Gal (Singe Edition): levels of moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. Written by util/forgePortKaris.lua from the game's script. +| Plays one of RDG2010's remakes: levels of moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. Written by util/forgePortKaris.lua from the game's script. | -- | `timer` diff --git a/docs/Manual.adoc b/docs/Manual.adoc index e611d60dd..a3fe85a83 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -24,22 +24,33 @@ own game are made available through a simple application programming interface Singe provides numerous features to the game developer. Some of the more interesting ones are: +* 3D scenes with models, animation, lighting, and shadows +* Physics, including joints, ragdolls, soft bodies, and vehicles +* Menus, option screens, and HUDs built like web pages +* Hardware video decoding, even on small machines +* Online high scores, and a catalogue of games to download and update +* Games packed into a single file * Object-oriented programming language * Animated sprites * TrueType font support * 32-bit color space with transparency -* Multi-channel, overlapping, stereo sound -* Analog and digital input device support +* Multi-channel, overlapping, stereo sound, and MIDI +* Analog and digital input device support, including light guns * Wide support for video and audio formats +Singe runs on Windows, macOS, and Linux, including the Raspberry Pi and +handhelds like the R36S. + For players, Singe allows many unemulated and new games to be enjoyed using any desired front end or via the included menu system. Since the original release of Singe back in 2006, several revisions have been released, both officially and unofficially. In 2020, Singe 2.00 was released. This was a total rewrite from the ground up, adding numerous features while -staying compatible with existing 1.xx games. As of the writing of this manual, -the latest release is {revnumber}. +staying compatible with existing 1.xx games. Singe 3.00 adds 3D, physics, +user interfaces, and much more, while still running the games that came +before it. As of the writing of this manual, the latest release is +{revnumber}. == Installation and Upgrading @@ -74,7 +85,7 @@ files. Always refresh the `Singe/` directory when upgrading. Do not touch the `Singe/` folder. Files in this folder belong to Singe and may be updated or deleted at any time. -== Usage +== Playing Games === Installing Games @@ -127,28 +138,29 @@ Singe/ Support files extracted by the engine Manual.pdf This manual COPYING The GNU General Public License, version 3 LICENSES Third-party notices and asset credits -ActionMax/ One game +Collection/ A directory of games games.dat Menu entries for the games in this directory - 38AmbushAlley.singe A script - frame_38AmbushAlley.txt Its framefile (or a video with the same base name) + AsteroidPatrol.singe A script + frame_AsteroidPatrol.txt Its framefile (or a video with the same base name) sprite_*.png, sound_*.wav, font_*.ttf -DLe.game A game packed into one file (see Single-File Games) +MyGame.game A game packed into one file (see Single-File Games) data/ machine.cfg This machine's audio delay and picture geometry - ActionMax/ Indexes, trace.txt, screenshots, saves for that game + Collection/ Indexes, trace.txt, screenshots, saves for those games ---- Where Singe looks for a script's files, and where a script may write, is a matter for whoever writes the script; the Singe Reference covers both under Packaging Your Game. - [#bundledmenu] === The Bundled Menu `Menu.sh` (or `Menu.bat`) starts `Singe/Menu.singe`, the bundled menu: an -RmlUi document, `Singe/Menu.rml` styled by `Singe/menu.rcss`, drawn over the -engine's own disc (see xref:Reference.adoc#gui[GUI]). It reads the `games.dat` of every game +RmlUi document, `Singe/Menu.rml` styled by `Singe/menu.rcss`, drawn over a +live 3D backdrop (see xref:Reference.adoc#gui[GUI]). The menu runs without a +disc; a machine with no GPU plays `Singe/menuBackground.mkv`, a recording of +the same backdrop, behind a simpler page drawn on the overlay instead. It reads the `games.dat` of every game directory and `.game` file beside it and lists every game it finds, sorted by title, down the left of the screen. Beside the list are the selected game's cabinet art, its marquee and its attract clip, with the year, genre, @@ -160,9 +172,10 @@ a page at a time; both wrap from either end of the list to the other. Start or any action button launches the selected game, and the key mapped to `INPUT_SERVICE` (the `9` key by default) opens the service tools (see <>). With a mouse, a -click on a row selects that game and a second click on the same row, or a -click on the Start button in the footer, launches it; the mouse wheel -scrolls a description too long for its panel. The menu keeps its selection +click on a row selects that game and a second click on the same row launches +it; the mouse wheel scrolls a description too long for its panel. If you set +`--quitchord`, holding those buttons quits a game back to the menu, and quits +the menu back to your system (see <>). The menu keeps its selection in `menu.dat` in its data directory, so it comes back to the same game when one quits or when Singe is next started. That file is a Lua fragment the menu reads at startup and rewrites at shutdown: `GAME_SELECTED` is the game it was @@ -332,15 +345,60 @@ Turns the developer's overlay on and off. It stays on into the game, in the top left corner. See xref:Reference.adoc#statsenable[statsEnable]. [[controls]] -=== Customizing the Controls +== Controls By default, Singe maps controllers as if they were Xbox gamepads. If your controller is not recognized or you wish to change the default mappings as they appear to Singe, you can use the free SDL2 Gamepad Tool from http://generalarcade.com/gamepadtool/. +[[controlscfg]] +=== controls.cfg + +In addition to configuring your controller, you can remap all the input options +for Singe as a whole or for any individual game by using `controls.cfg` files. +When determining how inputs are mapped, Singe reads configuration settings in +the following order, and a later file replaces an earlier one input by input +(see <>): + +. The defaults from inside Singe. +. `controls.cfg` from the game's main script directory: what the game's author shipped. +. `controls.cfg` from the directory Singe was started in: the machine's own (the `Singe` directory holds a `controls.cfg.example` template you can copy). +. `controls.cfg` from the directory above the game's data directory. +. `controls.cfg` from the game's data directory: yours, for that one game. + +You do not have to specify every option in a custom `controls.cfg` file. Feel +free to just provide the entries you want changed. + +After installing Singe, you can find a sample input configuration file at +`/Singe/controls.cfg.example`. To see the available configuration +options available to be used in this file, read through +`/Singe/Framework.singe`. + +`--keymapfile` names one file to read instead of making that search, which is +useful to a front end that wants no doubt about which mappings a game got. + +Each `INPUT_` entry is a list, and any one thing in it works the switch. A list +inside that list is a chord: two to four keys or buttons held together, which +works the switch when the last of them goes down and lets go when any of them +comes up. A handheld with no Escape key quits on Select and Start together +this way: + +[source,lua] +---- +INPUT_QUIT = { SCANCODE.ESCAPE, { GAMEPAD_0.BUTTON_BACK, GAMEPAD_0.BUTTON_START } } +---- + +Each key or button in a chord keeps its own meaning too, so pressing Select and +Start to quit this way also drops a coin and presses Start on the way out. + +If you want a way to quit that always works, use `--quitchord` instead. When +you press one of its buttons, Singe waits a quarter of a second for the rest +before doing anything else, so quitting does not drop a coin or press Start. A +game can turn its own quit key off, but it cannot turn off `--quitchord`. + [[devices]] -==== Every Device Is a Gamepad +=== Every Device Is a Gamepad Singe has one family of controller, `GAMEPAD_0` through `GAMEPAD_3`, and every device it opens is in it. SDL calls a device a gamepad only when it has a @@ -369,24 +427,34 @@ have it used instead. not recognise is not opened at all, exactly as in earlier releases. [[gamecontrollerdb]] -==== gamecontrollerdb.txt +=== gamecontrollerdb.txt SDL ships with a large database of controller mappings, and the community keeps a larger one -- `gamecontrollerdb.txt`, from https://github.com/mdqinc/SDL_GameControllerDB. Singe looks for that file in -the same four places it looks for `controls.cfg`: +the same four places it looks for `controls.cfg`, in the same order (see +<>): +. the game's main script directory; . the directory Singe was started in; . the directory above the game's data directory; -. the game's data directory; -. the game's main script directory. +. the game's data directory. -Every file found is loaded, in that order, so one beside a game wins over a -machine-wide one, and both win over SDL's own database. Only a device that +Every file found is loaded, in that order, so the machine's file wins over one +a game ships, a file in the game's data directory wins over both, and all of +them win over SDL's own database. Only a device that none of them names gets the mapping Singe writes for it, so the order of precedence is: SDL's built-in database, then your `gamecontrollerdb.txt`, then the synthetic mapping. `--program` reports how many mappings each file added. +The files also beat `SDL_GAMECONTROLLERCONFIG`, the environment variable SDL +takes mappings from, which some handheld systems set for their built-in pad. +ArkOS's mapping for the R36S, for instance, leaves Select and Start out, so no +game sees them; a line in `gamecontrollerdb.txt` for the same pad puts them +back. SDL reads the variable once, when Singe starts, so this holds for the +files the first game finds. A game started later from the menu still reads its +own, but a pad the variable names keeps the variable's mapping. + A line of that file looks like this, and is exactly what the trace prints for a device Singe mapped itself: @@ -394,30 +462,9 @@ device Singe mapped itself: ff00d87a475300004900000000007603,Arcade Encoder,platform:Linux,a:b0,b:b1,x:b2,y:b3,back:b4,guide:b5,start:b6,leftx:a0,lefty:a1,dpup:h0.1,dpright:h0.2,dpdown:h0.4,dpleft:h0.8, ---- -In addition to configuring your controller, you can remap all the input options -for Singe as a whole or for any individual game by using `controls.cfg` files. -When determining how inputs are mapped, Singe reads configuration settings in -the following order: - -. The defaults from inside Singe. -. `controls.cfg` from the directory Singe was started in (the `Singe` directory holds a `controls.cfg.example` template you can copy). -. `controls.cfg` from the directory above the game's data directory. -. `controls.cfg` from the game's data directory. -. `controls.cfg` from the game's main script directory. - -You do not have to specify every option in a custom `controls.cfg` file. Feel -free to just provide the entries you want changed. - -After installing Singe, you can find a sample input configuration file at -`/Singe/controls.cfg.example`. To see the available configuration -options available to be used in this file, read through -`/Singe/Framework.singe`. - -`--keymapfile` names one file to read instead of making that search, which is -useful to a front end that wants no doubt about which mappings a game got. [[deadzones]] -==== Dead Zones and Triggers +=== Dead Zones and Triggers `DEAD_ZONE` in `controls.cfg` is how far an axis must move before it counts as a pressed switch, in the raw units SDL reports: `32767` at full deflection, so @@ -455,7 +502,7 @@ directly: a per cent is `32767 / 100` raw units, so Singe's `DEAD_ZONE` of `controls.cfg`; `DEAD_ZONES` wins over both, being the most specific. [[joymouse]] -==== Driving the Mouse with a Stick +=== Driving the Mouse with a Stick A light gun game on a cabinet with a stick and no mouse is unplayable unless the stick can move the cursor. `--joymouse` makes it do so: the **left stick of @@ -475,7 +522,7 @@ and reads it with `joyMouseIsEnabled`. A reload puts it back where the command line left it. [[mousedevices]] -==== Mice, Guns, and Who Chooses +=== Mice, Guns, and Who Chooses `mouseSetMode(MOUSE_MANY)` is a game's decision to tell up to four mice apart, which a two gun cabinet needs and which a game that never makes the call cannot @@ -501,7 +548,7 @@ name. `controls.cfg` leaves it bound to nothing; bind a key to it as well if you want to see what a game does without pulling a cable. [[hotkeys]] -==== Hotkeys +=== Hotkeys `Alt-Enter` switches between full screen and a window while the game runs, as it does in Hypseus. The game never sees the key. A window that started in @@ -517,6 +564,11 @@ The other switches a user can press -- quit, pause, screenshot, the console and the mouse capture toggle -- are ordinary `controls.cfg` mappings and are described with the `INPUT_*` names in the sample file. +== Settings and Options + +Almost everything you can put on the command line can also go in a settings +file. Set things up once and every game uses them. + [[settings]] === The Settings File @@ -525,15 +577,15 @@ Everything on the command line can also be written down once, in a same dozen options for every game. It is Lua, like `controls.cfg`, and Singe looks for it in exactly the same four places, in this order: +. `settings.cfg` from the game's main script directory. . `settings.cfg` from the directory Singe was started in (the `Singe` directory holds a `settings.cfg.example` template you can copy). . `settings.cfg` from the directory above the game's data directory. . `settings.cfg` from the game's data directory. -. `settings.cfg` from the game's main script directory. Each place is read once, and a later file wins key by key, so a machine-wide -file can set the display and the volumes while a file beside one game turns off -its crosshair. You do not have to write every key in a file; write the ones you -want changed. +file can set the display and the volumes while a file in one game's data +directory turns off that game's crosshair. You do not have to write every key +in a file; write the ones you want changed. Every key is the long name of a command line option with the leading dashes removed, so there is no second vocabulary to learn: @@ -558,7 +610,32 @@ One option, `--deterministic`, takes a value or leaves it out, and its key does the same: `deterministic = true` uses the default step, `deterministic = 20` names one, and `deterministic = false` leaves the option off. -==== What Wins +[[configorder]] +=== Where Configuration Files Are Read From + +`controls.cfg`, `settings.cfg` and `gamecontrollerdb.txt` are all looked for in +the same four places, in the same order, and a file read later wins over one +read earlier: + +. *The game's main script directory,* which for a packed game is inside its + `.game` file. This is what the game's author shipped, so it is read first: + it sets the game's defaults and nothing more. +. *The directory Singe was started in.* The machine's own files, such as a + handheld's button layout, go here, and they win over what a game ships. A + file only replaces what it names, so to take a game's binding off a button, + name that input: `INPUT_TEST_MODE = { SCANCODE.F2 }` in the machine's + `controls.cfg` leaves test mode on the keyboard only, whatever button a game + put it on. +. *The directory above the game's data directory,* for files shared by every + game using that data directory. +. *The game's data directory.* Your own files for one game, which win over + everything else. + +Without `--datadir` the data directory can be the game's own directory, in +which case the file there is read once, as the game's. `--keymapfile` replaces +the whole search for `controls.cfg` with one named file. + +=== What Wins . The built in default. . The settings file. @@ -601,8 +678,8 @@ Singe [OPTIONS] gameName ---- The game name is the only required argument. It may be a `.singe` script, a -directory containing a script of the same name (`ActionMax` finds -`ActionMax/ActionMax.singe`), or a packed game (`DLe.game` runs the first +directory containing a script of the same name (`MyGame` finds +`MyGame/MyGame.singe`), or a packed game (`MyGame.game` runs the first entry of the `games.dat` inside it, `--entry=N` another). A script or directory launched this way also runs with its `games.dat` entry when one names it (see xref:Reference.adoc#gamesdat[games.dat]), so a game plays the same from the command line as from @@ -610,79 +687,148 @@ the menu. For a laserdisc game (`--disc`) with no `--framefile`, Singe looks for a video next to the script with the same base name and any extension FFmpeg can demux, then for a `.txt` framefile. +The options are grouped by what they do. + +==== The Game and Its Files + +Which game runs, where it and its data live, and what it is played on. + [cols="1,2",options="header"] |=== | Option | Purpose -| `-A`, `--audiodelay=MS` | Compensate for audio that is heard `MS` milliseconds later than the engine can measure (negative when it is heard early), `-1000` to `1000`. See xref:Reference.adoc#audiosync[Audio Sync]. -| `-a`, `--aspect=N:D` | Force the aspect ratio used to pick a window size (`4:3`, `16:9`, `16:10`). -| `-B`, `--bezel=FILENAME` | Draw cabinet artwork around the picture. The file is looked for in the `bezels` folder of the game directory (a packed game may carry its own) and then in the `bezels` folder of the data directory; `--bezeldir` names a different folder. Any format SDL_image reads will do, and transparency is kept. A sidecar beside it says where the picture goes inside it; with none, the picture keeps the whole window and `--shiftx`, `--shifty` and `--scalefactor` place it. Artwork that cannot be found ends the program. See <>. -| `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`25` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. A script may change it while the game runs with `vldpSetScale`. Combines with `--shiftx`, `--shifty`, `--rotate` and `--sindengun`; see <>. -| `-c`, `--showcalculated` | Print the frame ranges of every segment of a framefile, for debugging. | `-C`, `--canvas=WxH` | World size for a game without a disc, default 720x480. Ignored when there is a disc. -| `-D`, `--disc` | Play a laserdisc video: the one named by `--framefile`, or the video found next to the script. Implied by `--framefile`. Without it a video next to the script is reported and ignored. | `-d`, `--datadir=PATHNAME` | Directory for everything Singe writes: video indexes, `trace.txt`, screenshots, saves, the menu's `menu.dat`. A subdirectory named for the game's directory is created inside it (for a packed game, one named for the database and then for the script's directory inside it). Defaults to `data` in the game directory. Singe 2.x wrote beside the game instead; game directories may be read only, so nothing is written there any more. +| `-D`, `--disc` | Play a laserdisc video: the one named by `--framefile`, or the video found next to the script. Implied by `--framefile`. Without it a video next to the script is reported and ignored. | `-E`, `--entry=N` | Run the Nth entry of the `games.dat` inside a `.game` file (default 1). See xref:Reference.adoc#singlefile[Single-File Games]. -| `-e`, `--volume_nonvldp=PERCENT` | Sound effect and extra video volume, `0` to `100`. -| `-F`, `--bezelflip` | Draw the bezel artwork in front of the picture instead of behind it, which is what Hypseus does with its own bezels; the artwork's transparency is then the hole the game shows through. Without it the artwork is behind everything and the picture covers its cutout. A script chooses whether the overlay and the GUIs stay above the artwork with `setOverlayOnTop`. See <>. -| `-f`, `--fullscreen` | Exclusive full screen at the desktop resolution. -| `-G`, `--gamedir=PATHNAME` | The directory holding the games, the packed `.game` files and the `Singe` support folder, when the executable lives somewhere else. It becomes the working directory, so the game name, a relative `--datadir` and the packer's names all count from it; absolute paths work as they are. Defaults to the current directory. -| `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. The rings are drawn around whatever `--shiftx`, `--shifty` and `--scalefactor` left, so a border stays a border however the picture is placed; see <>. -| `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, the V4L2 decoder on the Pi, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use. -| `-h`, `--help` | Show the option summary and exit. -| `-I`, `--bezeldir=PATHNAME` | The folder holding `--bezel`'s artwork, in place of `bezels`. Relative to the game directory, or absolute. Only that folder is searched. See <>. -| `-k`, `--nologos` | Skip the splash screens. -| `-l`, `--volume_vldp=PERCENT` | Laserdisc volume, `0` to `100`. -| `-m`, `--nomouse` | Disable mouse and lightgun input entirely. -| `-n`, `--nocrosshair` | Ask the game not to draw its own crosshair. Exposed to scripts as `singeWantsCrosshairs()`. -| `-o`, `--audio=TRACK` | Default audio track (zero based) for videos with several, such as multi-language releases. -| `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See xref:Reference.adoc#singlefile[Single-File Games]. -| `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header]. -| `-S`, `--sindenedge=WHERE` | Where the Sinden border sits: `video` draws it around the picture, `window` at the edge of the screen. Without the option a bezel decides: artwork around the picture puts the border at the window's edge, because the gun's camera sees the whole screen, and with no artwork it goes around the picture as it always has. At the window's edge the two rings are drawn as rings, last of all, so the artwork between them and the picture stays visible. See <>. -| `-s`, `--nosound` | Mute all audio. -| `-T`, `--patch=GAME.game` | Replace files in the packed game from the directory or patch database named after the options, then exit. -| `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header]. -| `-r`, `--rotate=DEGREES` | Turn the whole presentation clockwise by `0`, `90`, `180` or `270` degrees, for a cabinet whose panel is mounted on its side. The video, the overlay, the GUIs, the 3D scene and the particles all turn together, a quarter turn swaps the shape of the picture so a portrait window is filled, and mouse and light gun positions are turned back so a click still lands where it is drawn. Only the four right angles are accepted. A script may change it with `vldpSetRotate`. -| `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work. -| `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused. -| `-u`, `--stretch` | Stretch the video to fill the window instead of keeping its aspect ratio. | `-v`, `--framefile=FILENAME` | Video file or framefile to use instead of the one found next to the script. +| `-G`, `--gamedir=PATHNAME` | The directory holding the games, the packed `.game` files and the `Singe` support folder, when the executable lives somewhere else. It becomes the working directory, so the game name, a relative `--datadir` and the packer's names all count from it; absolute paths work as they are. Defaults to the current directory. +| `-c`, `--showcalculated` | Print the frame ranges of every segment of a framefile, for debugging. +|=== + +==== Display + +The window and how the picture fills it. + +[cols="1,2",options="header"] +|=== +| Option | Purpose +| `-a`, `--aspect=N:D` | Force the aspect ratio used to pick a window size (`4:3`, `16:9`, `16:10`). +| `-f`, `--fullscreen` | Exclusive full screen at the desktop resolution. | `-w`, `--fullscreen_window` | Borderless window covering the desktop. -| `-X`, `--shiftx=PERCENT` | Move the picture horizontally inside the room `--scalefactor` left it, `-100` (hard against the left) to `100` (hard against the right), `0` centred. As in Hypseus, the shift is a percentage of that room and nothing more, so at `--scalefactor=100` there is no room and the shift does nothing: reduce the scale first. See <>. -| `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted. -| `-Y`, `--shifty=PERCENT` | Move the picture vertically inside the room `--scalefactor` left it, `-100` (hard against the top) to `100` (hard against the bottom), `0` centred. It does nothing at full scale, for the same reason. See <>. -| `-y`, `--yresolution=VALUE` | Window height. -| `-z`, `--noconsole` | Print nothing (and open no console window on Windows). -| `--absolutes_only` | Keep only the mice that report an absolute position, which is what a real light gun does and an ordinary mouse does not. ManyMouse cannot be asked what a device is, so a device counts as absolute once it has reported an absolute position and not before; see <>. Hypseus writes it `-absolutes-only`; the name here uses an underscore because a settings file key has to be a Lua name. Default: off. -| `--altaudio=SUFFIX` | Play `.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `lair.m2v` plays `lair-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none. -| `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <>. Nothing else is printed. -| `--deinterlace=MODE` | What an interlaced picture gets: `auto` (the default) deinterlaces only the frames the file marks interlaced, `on` deinterlaces every frame for a file whose flags are wrong, and `off` never touches the picture. A laserdisc held interlaced fields and a rip that kept them combs on a progressive display; a rip that was deinterlaced when it was made needs nothing here. One picture comes out for each one that goes in, so frame numbers never move. Default: `auto`. -| `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc follows the virtual clock too, its frames lasting the time they are worth, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see xref:Reference.adoc#deterministic[Deterministic Test Mode]. Default: off. -| `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`. -| `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order. -| `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for. -| `--idleexit=SECONDS` | Quit after `SECONDS` with no input of any kind -- no key, no button, no pad axis, no mouse. For an attract cabinet that should hand the screen back. `0` never quits. `0` to `86400`. Default: `0`. -| `--joymouse=BOOLEAN` | Let the left stick of gamepad slot `0` drive the mouse cursor, for a light gun game on a cabinet with no mouse. `--joymouse=false` is Hypseus's `-nojoymouse`, and a script sets it with `joyMouseEnable()`. See <>. Default: `false`, where Hypseus's default is on -- the shipped `controls.cfg` already binds that stick to the four directions. -| `--js_range=SPEED` | How fast that stick drives it, `1` to `20`, in video pixels a frame at full deflection. Default: `5`, as in Hypseus. -| `--keymapfile=FILENAME` | Read the control mappings from this file instead of searching the four places for `controls.cfg`, so a front end can be certain which mappings a game got. The built in defaults still apply underneath it, so a partial file still works, and a file that is not there ends the program. Default: the four place search, described in <>. | `--linearscale=BOOLEAN` | Smooth the overlay as the window scales it (`true`) or take the nearest pixel (`false`, which keeps pixel art crisp). Takes `true`, `false`, `yes`, `no`, `on`, `off`, `1` or `0`. A script changes it while running with `setOverlayLinearScale`. Default: `true`, which is what every Singe release has drawn; Hypseus's own default is the opposite. -| `--manymouse` | Tell up to four mice apart whatever the game asks for, which a two gun cabinet needs from a game that never calls `mouseSetMode(MOUSE_MANY)` itself. The user's choice outranks the game's: `mouseSetMode(MOUSE_SINGLE)` no longer switches it off. Default: off, and the game decides. -| `--mapjoysticks=BOOLEAN` | Write a gamepad mapping for a device SDL does not recognise, so an arcade encoder, a spinner, a wheel or a flight stick is opened as `GAMEPAD_0` to `GAMEPAD_3` like anything else. `false` leaves such a device unopened, as earlier releases did. See <>. Default: `true`. -| `--monochrome` | Start with the disc picture in grey. The overlay, the GUIs and the 3D scene keep their colour; a script does the same with `vldpSetMonochrome`, and undoes it. Default: off. -| `--nogamepad` | Ignore every gamepad, the counterpart of `--nomouse`. None is opened and no pad event reaches the game, so a stuck arcade encoder cannot press anything. Default: off. -| `--no_update_check` | Never ask the service whether a newer Singe exists. The menu otherwise looks once a day and shows what it found under the game's details; the Engine Update tool still works by hand. Default: off, so the daily look happens. -| `--refresh` | Set the `Singe` support folder aside as `old-Singe-`, named for the version that wrote it, and write a fresh one. For upgrading: a file an older release shipped and this one does not is left in the folder that was set aside rather than sitting in the live one. Nothing is deleted, and a second run sets its own aside as `old-Singe--1`. The Engine Update tool passes this to the Singe it restarts into. +| `-k`, `--nologos` | Skip the splash screens. +| `-r`, `--rotate=DEGREES` | Turn the whole presentation clockwise by `0`, `90`, `180` or `270` degrees, for a cabinet whose panel is mounted on its side. The video, the overlay, the GUIs, the 3D scene and the particles all turn together, a quarter turn swaps the shape of the picture so a portrait window is filled, and mouse and light gun positions are turned back so a click still lands where it is drawn. Only the four right angles are accepted. A script may change it with `vldpSetRotate`. | `--screen=N` | Open the window on display `N`, counting from `1` as Hypseus counts them. A number larger than the number of displays lists the displays that are there and exits. Default: the primary display. +| `-u`, `--stretch` | Stretch the video to fill the window instead of keeping its aspect ratio. +| `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted. A size larger than the display -- asked for here or by a game's `games.dat` -- is shrunk to fit it, keeping its shape, and the trace says so. +| `-y`, `--yresolution=VALUE` | Window height. +|=== + +==== Picture Placement, Bezels and Guns + +For a cabinet: where the picture sits, the artwork around it, and the border a Sinden gun tracks. See <>. + +[cols="1,2",options="header"] +|=== +| Option | Purpose +| `-B`, `--bezel=FILENAME` | Draw cabinet artwork around the picture. The file is looked for in the `bezels` folder of the game directory (a packed game may carry its own) and then in the `bezels` folder of the data directory; `--bezeldir` names a different folder. Any format SDL_image reads will do, and transparency is kept. A sidecar beside it says where the picture goes inside it; with none, the picture keeps the whole window and `--shiftx`, `--shifty` and `--scalefactor` place it. Artwork that cannot be found ends the program. See <>. +| `-I`, `--bezeldir=PATHNAME` | The folder holding `--bezel`'s artwork, in place of `bezels`. Relative to the game directory, or absolute. Only that folder is searched. See <>. +| `-F`, `--bezelflip` | Draw the bezel artwork in front of the picture instead of behind it, which is what Hypseus does with its own bezels; the artwork's transparency is then the hole the game shows through. Without it the artwork is behind everything and the picture covers its cutout. A script chooses whether the overlay and the GUIs stay above the artwork with `setOverlayOnTop`. See <>. +| `-b`, `--scalefactor=PERCENT` | Shrink the picture about its centre to `PERCENT` (`25` to `100`) of the window for displays that lose their edges to overscan. Mouse and light gun positions follow the shrunken picture. A script may change it while the game runs with `vldpSetScale`. Combines with `--shiftx`, `--shifty`, `--rotate` and `--sindengun`; see <>. +| `-X`, `--shiftx=PERCENT` | Move the picture horizontally inside the room `--scalefactor` left it, `-100` (hard against the left) to `100` (hard against the right), `0` centred. As in Hypseus, the shift is a percentage of that room and nothing more, so at `--scalefactor=100` there is no room and the shift does nothing: reduce the scale first. See <>. +| `-Y`, `--shifty=PERCENT` | Move the picture vertically inside the room `--scalefactor` left it, `-100` (hard against the top) to `100` (hard against the bottom), `0` centred. It does nothing at full scale, for the same reason. See <>. +| `-S`, `--sindenedge=WHERE` | Where the Sinden border sits: `video` draws it around the picture, `window` at the edge of the screen. Without the option a bezel decides: artwork around the picture puts the border at the window's edge, because the gun's camera sees the whole screen, and with no artwork it goes around the picture as it always has. At the window's edge the two rings are drawn as rings, last of all, so the artwork between them and the picture stays visible. See <>. +| `-g`, `--sindengun='PARAMS'` | Draw the border a Sinden light gun tracks and map mouse and light gun positions into the picture inside it: the black ring, when one is given, runs along the edge of the video, the white ring sits inside it, and the game is drawn in the middle. `PARAMS` is one quoted list of numbers: `WW` (white border width in video pixels); `WW WB` (white width, then a black border outside it); `RW GW BW WW` (a custom border color and width); `RW GW BW WW WB` (the same plus a black width); or `RW GW BW WW RB GB BB WB` (custom colors and widths for both). A border that leaves no room for the game (twice the white and black widths together reaching the smaller side of the video), or a negative width, ends the program with a message. The rings are drawn around whatever `--shiftx`, `--shifty` and `--scalefactor` left, so a border stays a border however the picture is placed; see <>. +|=== + +==== Sound + +[cols="1,2",options="header"] +|=== +| Option | Purpose +| `--altaudio=SUFFIX` | Play `.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `game.m2v` plays `game-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none. +| `-o`, `--audio=TRACK` | Default audio track (zero based) for videos with several, such as multi-language releases. +| `-A`, `--audiodelay=MS` | Compensate for audio that is heard `MS` milliseconds later than the engine can measure (negative when it is heard early), `-1000` to `1000`. See xref:Reference.adoc#audiosync[Audio Sync]. +| `-s`, `--nosound` | Mute all audio. | `--soundfont=FILE` | The SoundFont (`.sf2`) MIDI files are synthesised with. Singe ships none, because a good one is tens of megabytes for a format almost no game uses. Without this option it looks for `Singe/soundfont.sf2` -- so a packed game can carry its own -- and then in the places a distribution installs one. The `SoundFont:` line of the trace header says which was used, or that none was found. See xref:Reference.adoc#midi[MIDI]. Default: the search. | `--startsilent` | Start muted and stay muted until the first input of any kind, then play at the configured volumes. For an attract cabinet in a quiet room. `--nosound` outranks it. Default: off. +| `-e`, `--volume_nonvldp=PERCENT` | Sound effect and extra video volume, `0` to `100`. +| `-l`, `--volume_vldp=PERCENT` | Laserdisc volume, `0` to `100`. +|=== + +==== Input + +See <>. + +[cols="1,2",options="header"] +|=== +| Option | Purpose +| `--absolutes_only` | Keep only the mice that report an absolute position, which is what a real light gun does and an ordinary mouse does not. ManyMouse cannot be asked what a device is, so a device counts as absolute once it has reported an absolute position and not before; see <>. Hypseus writes it `-absolutes-only`; the name here uses an underscore because a settings file key has to be a Lua name. Default: off. +| `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order. +| `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for. +| `--joymouse=BOOLEAN` | Let the left stick of gamepad slot `0` drive the mouse cursor, for a light gun game on a cabinet with no mouse. `--joymouse=false` is Hypseus's `-nojoymouse`, and a script sets it with `joyMouseEnable()`. See <>. Default: `false`, where Hypseus's default is on -- the shipped `controls.cfg` already binds that stick to the four directions. +| `--js_range=SPEED` | How fast that stick drives it, `1` to `20`, in video pixels a frame at full deflection. Default: `5`, as in Hypseus. +| `--keymapfile=FILENAME` | Read the control mappings from this file instead of searching the four places for `controls.cfg`, so a front end can be certain which mappings a game got. The built in defaults still apply underneath it, so a partial file still works, and a file that is not there ends the program. Default: the four place search, described in <>. +| `--manymouse` | Tell up to four mice apart whatever the game asks for, which a two gun cabinet needs from a game that never calls `mouseSetMode(MOUSE_MANY)` itself. The user's choice outranks the game's: `mouseSetMode(MOUSE_SINGLE)` no longer switches it off. Default: off, and the game decides. +| `--mapjoysticks=BOOLEAN` | Write a gamepad mapping for a device SDL does not recognise, so an arcade encoder, a spinner, a wheel or a flight stick is opened as `GAMEPAD_0` to `GAMEPAD_3` like anything else. `false` leaves such a device unopened, as earlier releases did. See <>. Default: `true`. +| `-n`, `--nocrosshair` | Ask the game not to draw its own crosshair. Exposed to scripts as `singeWantsCrosshairs()`. +| `--nogamepad` | Ignore every gamepad, the counterpart of `--nomouse`. None is opened and no pad event reaches the game, so a stuck arcade encoder cannot press anything. Default: off. +| `-m`, `--nomouse` | Disable mouse and lightgun input entirely. +| `--quitchord=BUTTONS` | Buttons on the first gamepad that quit when held together, whatever the game maps and even when it has turned its quit key off: the way out on a handheld with no keyboard. A game quits back to the menu it was started from, and the menu quits Singe. Two to four of SDL's gamepad button names joined by `+`, such as `back+start` (Select and Start); the names are `a`, `b`, `x`, `y`, `back`, `guide`, `start`, `leftstick`, `rightstick`, `leftshoulder`, `rightshoulder`, `dpup`, `dpdown`, `dpleft` and `dpright`, as the pad's mapping gives them, and `--program` traces the name of every button pressed. A press of one of the chord's buttons waits a quarter of a second for the rest before it does what it normally does, so Start does not launch a game from the menu on the way to quitting it. Belongs in the machine's `settings.cfg`. Default: none. | `--trigger_threshold=PERCENT` | How far an analogue trigger must travel before it counts as a button, as a per cent of full travel -- Hypseus's unit, whose own default is `99.5`. `0` leaves the triggers on `DEAD_ZONE`, which is what Singe has always given them. Wins over `TRIGGER_THRESHOLD` in `controls.cfg`; `DEAD_ZONES` wins over both. `0` to `100`; see <>. Default: `0`. | `--xratio=FACTOR` | The horizontal scale a light gun game reads with `ratioGetX()` to stretch its own gun coordinates for a display whose shape does not match the video. Singe does nothing with it; the game does the arithmetic, exactly as in Hypseus. `0` to `100`, kept to two decimals as Hypseus keeps it. Default: `0`, which every game that reads it treats as `1`. | `--yratio=FACTOR` | The same for `ratioGetY()`. Default: `0`. |=== +==== Video Decoding and Performance + +Mostly for small machines; see <>. + +[cols="1,2",options="header"] +|=== +| Option | Purpose +| `--deinterlace=MODE` | What an interlaced picture gets: `auto` (the default) deinterlaces only the frames the file marks interlaced, `on` deinterlaces every frame for a file whose flags are wrong, and `off` never touches the picture. A laserdisc held interlaced fields and a rip that kept them combs on a progressive display; a rip that was deinterlaced when it was made needs nothing here. One picture comes out for each one that goes in, so frame numbers never move. Default: `auto`. +| `--monochrome` | Start with the disc picture in grey. The overlay, the GUIs and the 3D scene keep their colour; a script does the same with `vldpSetMonochrome`, and undoes it. Default: off. +| `--scenequality=LEVEL` | How much the 3D scene draws: `high`, everything a game asks for, or `low` for a small GPU -- a handheld or an early Raspberry Pi -- which draws the scene at half resolution each way and leaves off antialiasing and bloom whatever the game asks. The 3D picture is softer and bright things have no glow, and it runs much faster where the GPU was the limit; the disc, the overlay and the GUIs are untouched. Belongs in the machine's `settings.cfg` rather than on a game's command line, since it describes the machine. Default: `high`. +| `-H`, `--softwarevideo` | Decode video in software even when the platform offers a hardware decoder (VA-API or VDPAU on Linux, the V4L2 decoder on the Pi, D3D11VA on Windows, VideoToolbox on macOS). Use it to rule the hardware path in or out when a video misbehaves; the program trace says which decoder is in use. +| `--zerocopy=BOOLEAN` | Hand hardware decoded video frames straight to the GPU instead of copying them through the CPU: Rockchip's MPP decoder and VA-API on Linux, drawn with OpenGL ES. It saves most of the cost of full screen video on a small machine. Singe falls back to copying by itself when the driver will not take a frame, or when a game reads or changes the picture's pixels, and the program trace says which it is doing; `false` makes it copy from the start, to rule this path in or out. Default: `true`. +|=== + +==== Cabinets and Front Ends + +[cols="1,2",options="header"] +|=== +| Option | Purpose +| `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <>. Nothing else is printed. +| `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`. +| `-h`, `--help` | Show the option summary and exit. +| `--idleexit=SECONDS` | Quit after `SECONDS` with no input of any kind -- no key, no button, no pad axis, no mouse. For an attract cabinet that should hand the screen back. `0` never quits. `0` to `86400`. Default: `0`. +| `--no_update_check` | Never ask the service whether a newer Singe exists. The menu otherwise looks once a day and shows what it found under the game's details; the Engine Update tool still works by hand. Default: off, so the daily look happens. +| `-z`, `--noconsole` | Print nothing (and open no console window on Windows). +| `--refresh` | Set the `Singe` support folder aside as `old-Singe-`, named for the version that wrote it, and write a fresh one. For upgrading: a file an older release shipped and this one does not is left in the folder that was set aside rather than sitting in the live one. Nothing is deleted, and a second run sets its own aside as `old-Singe--1`. The Engine Update tool passes this to the Singe it restarts into. +|=== + +==== Making and Testing Games + +See <> for the traces. + +[cols="1,2",options="header"] +|=== +| Option | Purpose +| `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc follows the virtual clock too, its frames lasting the time they are worth, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see xref:Reference.adoc#deterministic[Deterministic Test Mode]. Default: off. +| `-P`, `--pack=DIRECTORY` | Pack the game in `DIRECTORY` into the database named after the options, then exit. See xref:Reference.adoc#singlefile[Single-File Games]. +| `-T`, `--patch=GAME.game` | Replace files in the packed game from the directory or patch database named after the options, then exit. +| `--perfsync` | With `--program`, wait for the GPU to finish each stage of a frame before timing the next, so the perf lines say which stage the GPU's time belongs to rather than leaving it all in `present`. Everything runs slower while it is on, so it is for measuring, not for playing; see <>. Default: off. +| `-p`, `--program` | Trace engine activity to the console and to `trace.txt` in the data directory. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header]. Every five seconds it also says where the frame's time went; see <>. +| `-R`, `--reload` | Watch the game's loose script files and run the game again from scratch when one changes; `F5` does the same on demand. A script that fails to compile stays watched, so saving the fix reloads it. For working on a game; see Reloading While You Work. +| `-t`, `--trace` | Trace every Lua API call, with the script line that made it, to the console and to `trace.txt`. The file opens with a header describing the machine; see xref:Reference.adoc#traceheader[The Trace Header]. +| `-U`, `--unpack=GAME.game` | Write the packed game's files into the directory named after the options, then exit. A database whose stored names would land outside that directory is refused. +|=== + Options may be given as `-x 640`, `-x640`, or `--xresolution=640`. Numeric -values are validated; a bad value prints the usage text and exits. The options -at the end of the table have no short letter; the alphabet ran out, and a letter -picked at random would help nobody. +values are validated; a bad value prints the usage text and exits. Newer options +have no short letter; the alphabet ran out, and a letter picked at random would +help nobody. Anything here except the options that name the game, name a directory, or must act before a file can be found may also be set in a settings file, so a cabinet @@ -690,7 +836,7 @@ does not need a launcher script for every game. See <>. [[apiversion]] -==== The Version Line +=== The Version Line `--apiversion` prints one line and exits with status `0`: @@ -711,6 +857,16 @@ This is a contract for front ends and will not change shape: * Later releases may add fields. A reader must ignore keys it does not know rather than treating them as an error. +[[cabinet]] +== Setting Up a Cabinet + +Arcade cabinets need a few things a desktop does not: moving the picture away +from edges the monitor hides, turning it for a sideways screen, artwork around +it, a border for a Sinden light gun, and a scoreboard. You only need this +chapter if you are building a cabinet. Set these once in the cabinet's +`settings.cfg`, or use the Display tool in the menu (see +<>) to adjust the picture while you look at it. + [[videorect]] === The Video Rectangle @@ -872,6 +1028,192 @@ xref:Reference.adoc#guidrawscreen[`guiDrawScreen`]. Hypseus's `scoreBezelEnable` takes a second argument choosing among its five scoreboard backends. Singe has one, so the argument is accepted and ignored. +[[handhelds]] +== Handhelds and Small Machines + +Singe runs on the Raspberry Pi and on inexpensive ARM handhelds like the R36S. +The same games work there as on a desktop, but a small machine needs a little +help to run them well. + +=== What a Small Machine Needs + +Sprites, text, sound, and video work everywhere. The 3D scene and the menu's +fancy look need a graphics chip with Vulkan or OpenGL ES 3.1. + +* A Raspberry Pi 4 or later has both, so everything works. A Pi 3 only has + OpenGL ES 2.0, which is not enough for 3D. It still runs 2D games. +* Most handhelds, the R36S included, have a Mali graphics chip with OpenGL ES + 3.1, so 3D works there too. +* On a machine without either, the menu uses a simpler look that does not need + them. + +The first time you run a 3D game, Singe builds its shaders, which can take a +few seconds on a small machine. They are saved in the `Singe` folder inside the +data directory, so this only happens once. + +=== Settings for a Small Machine + +Put these in the `settings.cfg` beside the Singe binary so every game uses +them: + +`scenequality = "low"`:: +Draws the 3D scene at half size, without antialiasing or bloom. 3D looks a +little softer, but runs much faster. Video, sprites, and menus are not +affected. + +`zerocopy = true`:: +This is already the default. Video goes from the hardware decoder straight to +the graphics chip without the CPU copying it. This is what lets a handheld play +laserdisc video at full speed. Only turn it off if a video misbehaves and you +want to rule it out. + +`quitchord = "back+start"`:: +Handhelds do not have an Escape key. Holding Select and Start quits the game +back to the menu, and quits the menu back to your system, no matter how the +game is set up. + +=== The R36S and ArkOS + +The R36S and similar handhelds running ArkOS use the Linux `aarch64` build of +Singe. Put the Singe binary, its `Singe` folder, `Menu.sh`, and your games in +one folder, such as `/roms/singe`. Run `Menu.sh` once so Singe can create its +support files. + +To start Singe from the Ports menu, add a script like the one below to +`/roms/ports`. These handhelds are slow to speed up their processor and +graphics chip, so the script sets them to full speed while Singe runs and puts +them back when it exits. + +[source,bash] +---- +#!/bin/bash +# /roms/ports/Singe.sh: Singe in the Ports menu. +SINGE_DIR=/roms/singe + +[ "$(id -u)" -eq 0 ] && ESUDO="" || ESUDO="sudo" +GOVERNORS=() +for g in /sys/devices/system/cpu/cpufreq/policy*/scaling_governor /sys/class/devfreq/*/governor; do + [ -f "$g" ] || continue + GOVERNORS+=("$g:$(cat "$g")") + echo performance | $ESUDO tee "$g" > /dev/null 2>&1 +done +restore() { + for entry in "${GOVERNORS[@]}"; do + echo "${entry##*:}" | $ESUDO tee "${entry%:*}" > /dev/null 2>&1 + done +} +trap restore EXIT + +cd "$SINGE_DIR" || exit 1 +./Menu.sh "$@" +---- + +ArkOS does not tell games about the Select and Start buttons, so the quit chord +will not work until you add this `gamecontrollerdb.txt` beside the Singe binary +(see <>): + +---- +1900bb3e4b4800000011000000010000,GO-Super Gamepad,a:b0,b:b1,x:b2,y:b3,back:b12,start:b13,leftshoulder:b4,rightshoulder:b5,dpup:b8,dpdown:b9,dpleft:b10,dpright:b11,leftx:a0,lefty:a1,rightx:a2,righty:a3,lefttrigger:b6,righttrigger:b7,platform:Linux, +---- + +If a button does not do what you expect, run Singe once with `--program` and +press it. The trace shows the name of each button you press, or its number if +the mapping above does not include it. + +Finally, add a `controls.cfg` beside the Singe binary. This one puts coin and +start on Select and Start, and keeps a game's test and service menus off the +face buttons, where some games put them. + +[source,lua] +---- +INPUT_1P_COIN = { SCANCODE.MAIN_5, SCANCODE.C, GAMEPAD_0.BUTTON_BACK } +INPUT_1P_START = { SCANCODE.MAIN_1, GAMEPAD_0.BUTTON_START } +INPUT_PAUSE = { SCANCODE.P } +INPUT_SCREENSHOT = { SCANCODE.F12, SCANCODE.F11 } +INPUT_SERVICE = { SCANCODE.MAIN_9 } +INPUT_TEST_MODE = { SCANCODE.F2 } +---- + +This file wins over the one a game includes (see +<>). Pause and screenshot +are moved to the keyboard because Singe normally puts them on Start and +Select. + +[[troubleshooting]] +== When Something Goes Wrong + +=== The Trace + +Run Singe with `--program` (or `-p`) to see what it is doing. Everything is +printed to the console and saved to `trace.txt` in the game's data directory. +If you report a problem, include `trace.txt` from a run with `--program`. +`--trace` (or `-t`) also logs every Lua call a game makes, which is mostly +useful when writing a game. + +If Singe crashes on Linux or macOS, it writes where it crashed to the console +and to `trace.txt`. + +`trace.txt` only has Singe's own messages. Messages from the sound system, the +video decoder, and the graphics driver only go to the console. When those might +be the problem, save the console too. On Linux: + +---- +./Menu.sh --program &> singe.log +---- + +[[perflines]] +=== Reading the Perf Lines + +With `--program`, every five seconds the trace says where the time went, as +averages per trip round the frame loop: + +---- +Perf: 58.9 frames/s shown, 353 loops; per loop 13.1 ms (worst 82.3): disc 1.1, script 0.1, scene 0.0, gui 0.4, overlay 0.0, compose 0.0, present 11.4, other 0.1 +Perf script: per loop 0.1 ms of Lua and 0.0 in engine calls it made; 0.0 in engine calls altogether: overlayClear 0.01 (0.8 calls), discGetFrame 0.00 (0.8 calls) +Video 3 perf: 23.8 frames/s decoded; per frame 0.5 ms, of which read back 0.0, convert 0.0 +---- + +* `disc` -- advancing the laserdisc video and putting its frame on the GPU. +* `script` -- the game's `onOverlayUpdate`, which the `Perf script` line splits + into the Lua itself and the engine calls it made, costliest calls first. +* `scene` -- drawing the 3D scene. +* `gui` -- laying out and drawing the GUI documents. +* `overlay` -- sending what changed on the overlay to the GPU. +* `compose` -- only with `--perfsync`: the GPU drawing the picture, the + overlay and the GUIs together. +* `present` -- showing the frame, which includes waiting for the screen's next + refresh. +* `other` -- everything else: input, sound, physics and the rest. + +A video's own line says how fast it decodes and how much of each frame went on +copying it back from the hardware decoder and converting its colours; zero for +both means its frames go to the GPU without being copied. + +The GPU works behind the CPU, so without `--perfsync` its time lands wherever +the CPU next has to wait for it, which is usually `present`. A large `present` +at the display's full rate is only the wait for the refresh; a large `present` +below it means the GPU is busy. `--perfsync` makes each stage wait for the GPU +before the next starts, so `scene`, `gui`, `overlay` and `compose` include +their GPU time and `present` is left with the wait for the refresh. The frame +rate drops while it is on, because the CPU and the GPU no longer work at the +same time; read the stages against each other, not against the frame rate. + +=== A Button Does Nothing + +Open the Input Test tool in the menu (see <>) +and press the button. If the tool sees it, the game is ignoring it, so check the +game's `controls.cfg`. If the tool does not see it, check your own +`controls.cfg`, or your controller's mapping in `gamecontrollerdb.txt`. +Running with `--program` shows every button as you press it. + +=== Sound and Picture Are Out of Sync + +Some televisions, receivers, and wireless speakers delay the sound. Use the +Audio Delay tool in the menu to measure the delay. Singe remembers it for every +game. See xref:Reference.adoc#audiosync[Audio Sync] for details. + +For choppy or stuttering sound, see the Frequently Asked Questions. + == Frequently Asked Questions *Why is it named Singe?* @@ -880,17 +1222,18 @@ Singe is the name of the dragon in Dragon's Lair. As Singe (the program) began as an add-on to Daphne (the princess in Dragon's Lair) the Dragon's Lair theme was kept. -*What is the difference between Daphne, Singe, Hypseus, and Singe 2.xx?* +*What is the difference between Daphne, Singe, Hypseus, and Singe 3.xx?* Daphne is an actual laserdisc game emulator that can run a handful of classic laser games. Daphne/Singe (Singe 1.xx) is the original version of Singe that was an add-on for the Daphne emulator -- originally shipped as a game DLL for Daphne, then later combined into a single binary with the Daphne emulation features removed. Hypseus-Singe is an enhanced fork of Daphne with Singe 1.xx -and some Singe 2.xx support. Finally, Singe 2.xx is an all-new, built-from- -scratch upgrade of the original Singe. In short, if you want accuracy, use -Daphne or Hypseus if they support emulating the desired game. If it is a Singe -game, use Singe 2.xx, or try Hypseus. +and some Singe 2.xx support. Singe 2.xx is an all-new, built-from-scratch +upgrade of the original Singe, and Singe 3.xx builds on it with 3D, physics, +and much more. In short, if you want accuracy, use Daphne or Hypseus if they +support emulating the desired game. If it is a Singe game, use Singe, or try +Hypseus. *Why should we not call it an "emulator"?* @@ -900,10 +1243,18 @@ perspective, it is business as usual. Singe, on the other hand, requires that every game be re-implemented (ported) to run on Singe. The end result may be very similar, but how it works is entirely different. -*Can I run Singe 1.xx games on Singe 2.xx?* +*Can I run Singe 1.xx and 2.xx games on Singe 3.xx?* -Yes, probably. But you likely will not want to. Almost everything from Singe -1.xx has been converted and enhanced for Singe 2.xx. +Yes, probably. Almost everything from Singe 1.xx has been converted and +enhanced for newer versions of Singe, and most Singe 2.xx games run as they +are. Games that use the older sprite functions may need a small change; see +xref:Reference.adoc#migrating[Migrating from Singe 2.10]. + +*Do I need a laserdisc game to use Singe?* + +No. Games do not need a disc at all. They can use sprites, video, 3D, and +anything else Singe offers. See xref:Reference.adoc#withoutadisc[Games Without +a Disc]. *Why does the game I just installed not show in the menu?* @@ -911,9 +1262,11 @@ Whoever packaged the game failed to include a proper `games.dat` file. *Why is HD video slow?* -Due to the way Singe accesses video files to provide frame seeking, it is -unable to offload video decoding to the video card. High definition video -requires a lot of CPU. +It should not be any more. Singe now uses your video card to decode video when +your system supports it, even on the Raspberry Pi and many handhelds. If HD +video is still slow, your system may not have hardware video decoding, and +then high definition video requires a lot of CPU. Running with `--program` +shows which decoder Singe is using. *Why does my audio stutter on Windows?* @@ -935,7 +1288,7 @@ do not allow you to use stereo, you can use Virtual Audio Cable to fix this: . You should have sound again. Load up Singe and your audio will be working and lag-free. -*Does 3D work on a Raspberry Pi?* +*Does 3D work on a Raspberry Pi or a handheld?* On a Raspberry Pi 4 or later, yes. They have both a Vulkan driver and OpenGL ES 3.1, and Singe uses whichever it finds. The Pi 3 has only OpenGL ES 2.0, @@ -943,7 +1296,7 @@ which cannot express the scene's skinning or morph targets, so it runs 2D games (2D physics included) exactly as before and refuses the first 3D call. The Pi 4 is the minimum for any game that uses the 3D Scenes chapter. The same applies to the cheap handhelds: a Mali-G31 or G610 has the ES 3.1 the scene needs, whether -or not it has Vulkan. +or not it has Vulkan. See <>. *Why does my audio stutter on the Raspberry Pi?* diff --git a/docs/Reference.adoc b/docs/Reference.adoc index 4e286de66..023d09136 100644 --- a/docs/Reference.adoc +++ b/docs/Reference.adoc @@ -187,11 +187,11 @@ installing the game is copying the file next to the executable. The menu lists every `.game` file it finds beside the game directories. ---- -Singe --pack DLe DLe.game Pack the DLe directory into DLe.game -Singe DLe.game Run the first games.dat entry in it -Singe --entry=2 DLe.game Run its second entry -Singe --unpack DLe.game DLe Write the files back out -Singe --patch DLe.game fixes Replace files from a directory (or a patch database) +Singe --pack MyGame MyGame.game Pack the MyGame directory into MyGame.game +Singe MyGame.game Run the first games.dat entry in it +Singe --entry=2 MyGame.game Run its second entry +Singe --unpack MyGame.game MyGame Write the files back out +Singe --patch MyGame.game fixes Replace files from a directory (or a patch database) ---- Nothing in a game has to change to be packed. Every name a script uses, @@ -203,7 +203,7 @@ exactly as before. For a packed game a name is tried in three places, in this order, and the first hit wins: . A loose directory named like the database without its extension - (`DLe/` beside `DLe.game`). This is how you keep editing a packed game, + (`MyGame/` beside `MyGame.game`). This is how you keep editing a packed game, and how a player mods one: files dropped there beat the packed copies. . The game's data directory, under `files/`. Everything a game writes with `io.output` or `io.open(name, "w")` lands here, so a saved @@ -212,9 +212,9 @@ this order, and the first hit wins: Inside a database, names are matched without regard to case, with either path separator, and relative to the game root. A leading component equal to -the game's own directory name is ignored, so `DLe/Cfg/game.cfg`, +the game's own directory name is ignored, so `MyGame/Cfg/game.cfg`, `Cfg/game.cfg`, and `DIR .. "Cfg/game.cfg"` all find the same file. The -name of the database itself (`DLe.game`, with or without a trailing +name of the database itself (`MyGame.game`, with or without a trailing separator) is the game root, as `.` is from inside. Names starting with `Singe/` or with the data directory stay on the filesystem; `Singe/Framework.singe` and `singeGetDataPath()` work as always, and such a @@ -268,7 +268,7 @@ patched game, so a patch cannot leave a dangling path behind either. `--patch` takes either a directory laid out like the game, whose files replace the packed ones, or a `.patch` file made by `--pack` from such a -directory (`Singe --pack fixes DLe-1.1.patch`; a patch needs no +directory (`Singe --pack fixes MyGame-1.1.patch`; a patch needs no `games.dat`). Files the patch does not name are left alone. Either way the change is one transaction: the game is intact if it is interrupted. A patch may also carry a `removed(path)` table naming files to delete. A `.patch` @@ -292,15 +292,15 @@ gain. Nothing else in Singe needs this; a game is only ever written by the packer. A packed game's data directory is named for the database and then for the -script's directory inside it: `DLe/` for a `DLe.game` whose script sits at -its root under the data directory (`data/DLe/` by default), which is where the loose -`DLe/DLe.singe` has it, so saves and settings are shared between a loose +script's directory inside it: `MyGame/` for a `MyGame.game` whose script sits at +its root under the data directory (`data/MyGame/` by default), which is where the loose +`MyGame/MyGame.singe` has it, so saves and settings are shared between a loose install and a packed one; a script packed in a subdirectory -(`Game/Main.singe`) gets `DLe/Game/`. The video index goes there too. +(`Game/Main.singe`) gets `MyGame/Game/`. The video index goes there too. NOTE: Earlier 3.00 builds gave a packed game whose script sat in a subdirectory the data directory `Game/` under the base, which every archive -with that layout shared. Such a game's saves now belong under `DLe/Game/`; +with that layout shared. Such a game's saves now belong under `MyGame/Game/`; move them by hand. When Singe runs a game from a database the menu passes `CONTAINER` in the `games.dat` entry it launches; the field is set by the menu, never by hand. @@ -556,26 +556,26 @@ of your game. An example containing multiple games: ---- GAMES = { { - TITLE = ".38 Ambush Alley", - SCRIPT = "ActionMax/38AmbushAlley.singe", - VIDEO = "ActionMax/frame_38AmbushAlley.txt", + TITLE = "Asteroid Patrol", + SCRIPT = "Collection/AsteroidPatrol.singe", + VIDEO = "Collection/frame_AsteroidPatrol.txt", STRETCH = false, NO_MOUSE = false, RESOLUTION_X = 720, RESOLUTION_Y = 480, SINDEN_GUN = "", GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10", - CABINET = "ActionMax/cabinet_38AmbushAlley.png", - MARQUEE = "ActionMax/marquee_ActionMax.png", - ATTRACT = "ActionMax/video_38AmbushAlley.mkv", + CABINET = "Collection/cabinet_AsteroidPatrol.png", + MARQUEE = "Collection/marquee_Collection.png", + ATTRACT = "Collection/video_AsteroidPatrol.mkv", ATTRACT_START = 3000, ATTRACT_END = 3500, - YEAR = 1987, - PLATFORM = "ActionMax", - DEVELOPER = "Sourcing International, Ltd.", - PUBLISHER = "Worlds of Wonder, Inc.", + YEAR = 2026, + PLATFORM = "Laserdisc", + DEVELOPER = "Your Studio", + PUBLISHER = "Your Studio", GENRE = "Shooter", - DESCRIPTION = "Get your target practice in with real police officers then hit the streets.", + DESCRIPTION = "Keep the asteroids away from the station for as long as you can.", CREATOR = "Scott Duensing", SOURCE = "http://kangaroopunch.com" }, @@ -601,7 +601,7 @@ A script launched directly, by name or by its directory, gets the same treatment: Singe looks for a `games.dat` in the script's directory and up to three directories above it, and if an entry's `SCRIPT` names the launched script (entry paths count from the directory above the `games.dat`, so -`ActionMax/games.dat` names `ActionMax/38AmbushAlley.singe`), that entry's +`Collection/games.dat` names `Collection/AsteroidPatrol.singe`), that entry's keys apply. The one difference from a menu launch is precedence: an option typed on the command line (`--framefile`, `--disc`, `--stretch`, `--nomouse`, `--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`, @@ -639,8 +639,9 @@ document written in RML (an HTML-like markup) and styled by RCSS (a CSS-like style sheet), laid out and drawn by RmlUi, with text wrapping, flexbox, nine-slice panels from your own artwork, transitions, animations, form controls and a focus model a gamepad can drive. A *GUI* is one RmlUi -context of a fixed size rendered into its own texture every frame, and that -texture goes wherever a texture can: composited over the overlay with +context of a fixed size rendered into its own texture, redrawn whenever its +picture changes (see <>), and that texture goes +wherever a texture can: composited over the overlay with `guiDraw`, or shown on a material in the 3D scene with `materialSetGui`, so the same options page works flat on the screen and on the monitor of a modeled arcade cabinet. A GUI needs the GPU device the 3D scene uses, so it @@ -684,9 +685,9 @@ under `Singe/`, and absolute paths. The same rule read the other way lets a document that lives under `Singe/` reach a game's files: a relative name resolves from the document's own -directory, so from `Singe/Menu.rml` the name `../ActionMax/cabinet.png` is -`ActionMax/cabinet.png` in the game directory, and a name under -`../DLe.game/` is a file inside that packed game. The bundled menu does +directory, so from `Singe/Menu.rml` the name `../Collection/cabinet.png` is +`Collection/cabinet.png` in the game directory, and a name under +`../MyGame.game/` is a file inside that packed game. The bundled menu does exactly this for the cabinet and marquee art of the selected game, setting an image decorator on the art panel from Lua so the picture is fitted inside it whatever its size: @@ -952,11 +953,13 @@ the script makes them again when it runs; nothing from the `rmlui` object model survives, so do not keep those objects in globals a reload would reuse. -Every GUI updates and renders into its texture every frame whether or not -it is drawn anywhere, so a document keeps running its animations and -scripts off screen, and a GUI that is finished with should be deleted -rather than merely hidden. The cost is the size of the texture and the -complexity of the document: on a desktop a full screen page is cheap, on a +Every GUI updates every frame whether or not it is drawn anywhere, so a +document keeps running its animations and scripts off screen, and a GUI +that is finished with should be deleted rather than merely hidden. It is +redrawn into its texture only when its picture changes, so a page or a HUD +that stands still costs the GPU almost nothing (see +<>). The cost of a frame that does change is the +size of the texture and the complexity of the document: on a desktop a full screen page is cheap, on a Raspberry Pi keep a GUI to the overlay's size or smaller and prefer one GUI with several documents to several GUIs. Each font face costs an atlas per size it is used at, so a page that uses one face at three sizes pays @@ -964,6 +967,31 @@ for three atlases; a dozen sizes is a dozen. Sixteen GUIs may exist at once, each with thirty-two documents, and sixteen `guiDraw` calls are honored per frame. +What a document can show, and what it cannot: + +* *Pictures come from files.* Images, sprite sheets and image decorators + load PNG and the other formats Singe reads, from disk or from a packed + game. A document cannot show a Singe sprite, a video, the 3D scene or + anything drawn while the game runs, and a script cannot hand it pixels. + The other way round works: a GUI's texture goes onto a 3D material with + `materialSetGui`. +* *Decorators:* `image`, `text`, `tiled-horizontal`, `tiled-vertical`, + `tiled-box`, `ninepatch`, the two-colour `gradient` (and its + `horizontal-gradient` and `vertical-gradient` names), and `linear-gradient`, + `radial-gradient` and `conic-gradient` with their `repeating-` forms, with at + most sixteen colour stops. The custom `shader` decorator is refused. +* *Filters:* `opacity`, `blur`, `drop-shadow`, `brightness`, `contrast`, + `invert`, `grayscale`, `sepia`, `hue-rotate` and `saturate`. Any other + name is ignored with a warning in the trace. +* *Layers:* filters, `opacity` below 1, mask images and box shadows each + draw through a layer the size of the GUI, and at most eight stack at + once; one past that draws without its layer, and so without its effect. +* *Images* larger than 64 MB are refused. +* *RML and RCSS* are RmlUi's, close to HTML and CSS but not the same: there + is flexbox and no CSS grid, and a document is scripted in Lua, through + `guiSetHandler` and the `rmlui` object, rather than in JavaScript. RmlUi's + own documentation is the authority on what the languages hold. + ==== A Worked Example An options page over the disc: a panel from the shipped theme with a name @@ -2336,6 +2364,7 @@ manual: `docs/Forge.adoc` in the source tree, built to `Forge.html` and `Forge.pdf` beside this one. +[[migrating]] === Migrating from Singe 2.10 Singe 3.00 moved the sprite handle to the first argument of `spriteDraw`, @@ -2393,6 +2422,11 @@ changed shape, but a few behaviors did: is frozen until the key is pressed again, and `SWITCH_PAUSE` is delivered to the script only when the key has been disabled. It acts on the key press rather than the release. See <>. +* `fontPrint` keeps the strings it renders, and `overlayClear` refills only + what was drawn since the last clear, so a HUD reprinted every frame is + cheap. Rendering text with `fontToSprite` for performance is deprecated; + it still works, and is still the way to get text you can measure, scale, + or turn. See <>. === Engine Constants @@ -2496,8 +2530,8 @@ of audio arriving early. The bundled menu has a calibration screen for that downstream delay. Press the key mapped to `INPUT_SERVICE` (the `9` key by default) on the game list and -choose Audio Delay from the service tools (see <>). The screen is a page of the menu's document: it covers the menu in +choose Audio Delay from the service tools (see xref:Manual.adoc#servicetools[The Service +Tools]). The screen is a page of the menu's document: it covers the menu in black, shows the delay being tried and the device queue the engine measured, plays a click once a second and turns white for one frame at each flash. Adjust with left and right (10 ms) and up and down (1 ms) until the flash and @@ -2664,19 +2698,6 @@ morph target weights are all driven. See the Animation part of <>. [#animationgettime] -==== allowSocketCall - -[source,text] ----- -allowed = allowSocketCall() ----- - -Hypseus extension, always `false` in Singe. Hypseus answers whether the user let it send data to a stats server, which its `-noserversend` turns off; the sending itself is commented out there. Singe sends nothing anywhere, so nothing is permitted and the answer never changes. It exists so a game that asks does not stop on a missing function. - -*Returns:* a boolean, always `false`. - -*Since:* 3.00 - ==== animationGetTime [source,text] @@ -3806,6 +3827,7 @@ met but *where the mover ends up* -- sliding along a wall rather than stopping dead in it -- `require("bump")` and let it do that; it is bundled for exactly that reason. +[#collidecircles] ==== collideCircles [source,text] @@ -3825,6 +3847,7 @@ Whether two circles overlap. Cheaper than it looks: nothing takes a square root *Since:* 3.00. *See also:* <> +[#collidepointcircle] ==== collidePointCircle [source,text] @@ -3843,6 +3866,7 @@ Whether a point falls within a circle. The edge counts as inside. *Since:* 3.00. +[#collidepointpolygon] ==== collidePointPolygon [source,text] @@ -3877,6 +3901,7 @@ function onInputPressed(key) end ---- +[#collidepointrect] ==== collidePointRect [source,text] @@ -3907,6 +3932,7 @@ if collidePointRect(x, y, enemyX, enemyY, spriteGetWidth(enemy), spriteGetHeight end ---- +[#colliderectcircle] ==== collideRectCircle [source,text] @@ -3925,6 +3951,7 @@ Whether a rectangle and a circle overlap, by finding the rectangle's nearest poi *Since:* 3.00. +[#colliderects] ==== collideRects [source,text] @@ -3944,6 +3971,7 @@ Whether two rectangles overlap. The one you will reach for most. *Since:* 3.00. *See also:* <> +[#collidesegments] ==== collideSegments [source,text] @@ -4285,7 +4313,7 @@ for slot = 0, 3 do end ---- -[#controllerhowmany] +[#controllergetpadding] ==== controllerGetPadding [source,text] @@ -4300,6 +4328,7 @@ Hypseus extension, always `0` in Singe. Hypseus adds this offset to a switch cod *Since:* 3.00 *See also:* <> +[#controllerhowmany] ==== controllerHowMany [source,text] @@ -4307,7 +4336,7 @@ Hypseus extension, always `0` in Singe. Hypseus adds this offset to a switch cod count = controllerHowMany() ---- -Hypseus extension. How many gamepads are open, of the four slots Singe has. Since every joystick Singe can open becomes a gamepad (see <>), an arcade encoder or a spinner is counted here as well. +Hypseus extension. How many gamepads are open, of the four slots Singe has. Since every joystick Singe can open becomes a gamepad (see xref:Manual.adoc#devices[Every Device Is a Gamepad]), an arcade encoder or a spinner is counted here as well. The count changes as devices are plugged in and out, so read it when you need it rather than once at startup. It says nothing about which slots are filled: a device may leave slot 0 empty while slot 1 is in use, so <> is still the way to ask about a particular slot. @@ -4357,12 +4386,7 @@ function drawAttract() end ---- -[#debug] -=== Debug - -Two calls for seeing what a script is doing: `debugPrint` writes text, and `lineDraw` draws a line over the 3D scene for one frame (`physicsSetDebug` and `navDraw` draw through the same lines). Printed output goes to the console Singe was started from, so it is visible during development and harmless in a cabinet, where `--noconsole` silences it. When Singe runs with `--trace`, every API call including this one is also written with its arguments to `trace.txt` in the data directory, which is the way to get a permanent record. Command line options are listed in the chapter on running Singe; the ZeroBrane Studio integration in `zbstudio/` offers a debugger when printing is not enough. - -[[traceheader]] +[#controllersetpadding] ==== controllerSetPadding [source,text] @@ -4377,6 +4401,12 @@ Hypseus extension, accepted and ignored. See <> +[#debug] +=== Debug + +Two calls for seeing what a script is doing: `debugPrint` writes text, and `lineDraw` draws a line over the 3D scene for one frame (`physicsSetDebug` and `navDraw` draw through the same lines). Printed output goes to the console Singe was started from, so it is visible during development and harmless in a cabinet, where `--noconsole` silences it. When Singe runs with `--trace`, every API call including this one is also written with its arguments to `trace.txt` in the data directory, which is the way to get a permanent record. Command line options are listed in the chapter on running Singe; the ZeroBrane Studio integration in `zbstudio/` offers a debugger when printing is not enough. + +[[traceheader]] ==== The Trace Header Whichever of `--program` and `--trace` opened it, `trace.txt` begins with a @@ -4386,14 +4416,14 @@ whole into a bug report: ---- ---------------------------------------------------------------- Singe: v3.00 -Command: ./Singe -w -p -d data --screen 2 ActionMax +Command: ./Singe -w -p -d data --screen 2 MyGame OS: Linux 6.8.0-124-generic (x86_64) CPU: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz, 8 cores, 20177 MB Renderer: gpu, GPU driver vulkan Decoder: hardware vaapi or vdpau where the codec allows, software otherwise SDL: built 3.4.16, linked 3.4.16 Settings: haptic=0 screen=2 volume_vldp=80 -Game: ActionMax/ActionMax.singe +Game: MyGame/MyGame.singe ---------------------------------------------------------------- ---- @@ -4479,19 +4509,6 @@ end The *disc* is the main laserdisc video, the one Singe was originally built to emulate. A game has at most one disc, opened at startup from the `VIDEO` named in `games.dat` or from `--disc` / `--framefile` on the command line; a game that names none runs without a disc (see <>). All `disc*` functions operate on this single implicit disc and take no handle. Frame numbers are zero based positions in the video file (or in the combined framefile) and are the time reference for every branch decision in a laserdisc game. Without a disc the query functions return `0`, `discGetState` reports `DISC_EJECTED`, and the transport functions trace and do nothing. Extra clips layered on top of the disc are the job of the <> family. [#discaudio] -==== drawTransparent - -[source,text] ----- -drawTransparent(boolean) ----- - -Hypseus extension, accepted and ignored. It decides whether Hypseus sets colour key 0 on the sprites loaded after it, which is how that engine carries transparency. Singe carries transparency in the alpha channel, as <> already describes, so there is no key to turn on or off. - -*Returns:* nothing. - -*Since:* 3.00 - ==== discAudio [source,text] @@ -6091,7 +6108,7 @@ titleFont = fontLoad("Singe/FreeSansBold.ttf", 32) hudFont = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(titleFont) -titleSprite = fontToSprite("SPACE ACE") +titleSprite = fontToSprite("GAME OVER") fontSelect(hudFont) ---- @@ -6103,7 +6120,7 @@ fontSelect(hudFont) fontPrint(x, y, text) ---- -Renders a string with the selected font, the current quality, and the current foreground color (plus the background color for `FONT_QUALITY_SHADED`), then blits it onto the overlay with its top-left corner at `(x, y)`. Call it from `onOverlayUpdate`. A new text image is rendered and discarded on every call, which is fine for a few lines but expensive for text that never changes; `fontToSprite` renders once and draws many times. With no font selected the script ends with an error. +Renders a string with the selected font, the current quality, and the current foreground color (plus the background color for `FONT_QUALITY_SHADED`), then blits it onto the overlay with its top-left corner at `(x, y)`. Call it from `onOverlayUpdate`, every frame if need be: the engine keeps each string it renders, keyed by the font, quality, colors, and text, and a later call asking for the same one only blits it. Strings not printed for about a second are forgotten, and a font's strings go when it is unloaded, so text that changes costs no more than it always did and text that does not is nearly free. A whole page of high scores reprinted every frame is fine. With no font selected the script ends with an error. *Parameters:* @@ -6150,7 +6167,7 @@ Selects how `fontPrint` and `fontToSprite` rasterize text from now on. The setti ---- -- Blended text over the video, but a shaded label box for the debug readout. fontQuality(FONT_QUALITY_BLENDED) -titleSprite = fontToSprite("DRAGON'S LAIR") +titleSprite = fontToSprite("HIGH SCORES") colorBackground(0, 0, 0, 255) fontQuality(FONT_QUALITY_SHADED) @@ -6178,7 +6195,7 @@ Makes a loaded font the one used by `fontPrint` and `fontToSprite`. The selectio function onOverlayUpdate() overlayClear() fontSelect(titleFont) - fontPrint(TITLE_X, 40, "DRAGON'S LAIR") + fontPrint(TITLE_X, 40, "HIGH SCORES") fontSelect(hudFont) fontPrint(8, 8, "Score: " .. score) return OVERLAY_UPDATED @@ -6193,7 +6210,9 @@ end result = fontToSprite(text) ---- -Renders a string with the selected font, quality, and colors into a new sprite and returns its handle. The sprite is independent of the font from then on, so it survives `fontUnload`, and it works with every `sprite*` call: draw it, measure it, scale or rotate it, and free it with `spriteUnload`. Render once and draw many times for labels and headings; for a string that changes, render a new sprite and unload the old one. With no font selected the script ends with an error. +Renders a string with the selected font, quality, and colors into a new sprite and returns its handle. The sprite is independent of the font from then on, so it survives `fontUnload`, and it works with every `sprite*` call: draw it, measure it, scale or rotate it, and free it with `spriteUnload`. For a string that changes, render a new sprite and unload the old one. With no font selected the script ends with an error. + +NOTE: Rendering text to a sprite for performance is deprecated. `fontPrint` now keeps what it renders, so printing the same string every frame costs about what drawing a sprite made from it does, without the sprite to manage. Use `fontToSprite` when the text has to be a sprite: to scale it, rotate it, flip it, or measure it with `spriteGetWidth` and `spriteGetHeight`. *Returns:* integer sprite handle. @@ -6203,18 +6222,15 @@ Renders a string with the selected font, quality, and colors into a new sprite a .Example [source,lua] ---- --- Pre-render the game description, wrapping lines to the panel width. -descriptionLines = {} -for _, line in ipairs(wrapText(GAME_LIST[selected].DESCRIPTION, TEXT_W)) do - table.insert(descriptionLines, fontToSprite(line)) -end +-- A title that spins and grows in: text that has to be a sprite, to be turned and scaled. +titleSprite = fontToSprite("GAME OVER") +titleFrames = 0 function onOverlayUpdate() - local y = TEXT_Y - for _, line in ipairs(descriptionLines) do - spriteDraw(line, TEXT_X, y) - y = y + spriteGetHeight(line) + 1 - end + titleFrames = titleFrames + 1 + spriteRotate(titleSprite, (titleFrames * 12) % 360) + spriteScale(titleSprite, math.min(titleFrames / 30, 1)) + spriteDraw(titleSprite, overlayGetWidth() / 2, overlayGetHeight() / 2, true) return OVERLAY_UPDATED end ---- @@ -6368,20 +6384,6 @@ end ---- [#guigetheight] -==== getUserString - -[source,text] ----- -id = getUserString() ----- - -Hypseus extension, always an empty string. Despite the name it is not a string the user set: Hypseus answers the machine install ID it sends with network statistics, taken from the Windows `MachineGuid` or the Linux machine ID. Singe sends nothing anywhere and will not hand a game a fingerprint of the machine it is running on. - -*Returns:* a string, always empty. - -*Since:* 3.00 -*See also:* <> - ==== guiGetHeight [source,text] @@ -6413,7 +6415,7 @@ guiDraw(dialog, x, y) stats = guiGetStats() ---- -What rendering every GUI cost in the last completed frame, for tuning documents that use filters, masks and shadows (each of which draws through a layer the size of the GUI). The counters cover all GUIs together. +What rendering every GUI cost in the last completed frame, for tuning documents that use filters, masks and shadows (each of which draws through a layer the size of the GUI). The counters cover all GUIs together. They are all zero on a frame where no GUI's picture changed, since nothing was redrawn (see <>). *Returns:* A table with the fields `drawCalls` (indexed draws, including mask writes, composites and filter passes), `layerPushes` (layers entered for filters, mask images and box shadows), `filterPasses` (full-size passes and blits inside filter chains; a blur costs several), `maskWrites` (draws into the stencil clip mask), `layerBytes` (bytes of layer textures currently allocated; each is the size of its GUI at four bytes a pixel) and `passes` (render passes begun; every layer switch begins one). @@ -6590,6 +6592,30 @@ function onOverlayUpdate() end ---- +[#guirefresh] +==== guiRefresh + +[source,text] +---- +guiRefresh(gui) +---- + +Draws the GUI again on the next frame even if nothing about it seems to have changed. A GUI is only redrawn when its picture changes: when a frame's document draws exactly what the last frame drew, and nothing new was made in between, the texture already holds that picture and is kept, which saves the GPU most of a GUI's cost on the frames a HUD or a menu stands still. Changes made through RML, RCSS, the RmlUi Lua interface or the `gui*` calls are all seen. This call is the way out should one ever be missed -- a GUI that stops updating when it should -- and a game should not need it; if it does, that is worth reporting. A GUI that does not exist raises an error. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A workaround while a missed change is reported: redraw the HUD every frame. +function onOverlayUpdate() + guiRefresh(hud) + guiDraw(hud) + return OVERLAY_UPDATED +end +---- + [#guisethandler] ==== guiSetHandler @@ -6887,8 +6913,8 @@ end Every controller Singe opens is a gamepad and is read through the `controller*` calls; a device SDL does not recognise reaches them through a mapping Singe -writes for it, so there is nothing else to call. See <>. The two calls here are about letting a stick stand in for a mouse. +writes for it, so there is nothing else to call. See xref:Manual.adoc#devices[Every Device Is +a Gamepad]. The two calls here are about letting a stick stand in for a mouse. [#joymouseenable] ==== joyMouseEnable @@ -8360,6 +8386,7 @@ interfaces and any synthesiser or other program registering one. macOS uses CoreMIDI and Windows the multimedia MIDI calls, where output device `0` is usually the synthesiser the system itself provides. +[#midicloseinput] ==== midiCloseInput [source,text] @@ -8378,6 +8405,7 @@ Stops listening to the input port. <> stops firin *Since:* 3.00. *See also:* <> +[#midicloseoutput] ==== midiCloseOutput [source,text] @@ -8396,6 +8424,7 @@ Releases the output port. The sending calls answer `false` from now on. Closing *Since:* 3.00. *See also:* <> +[#midicontrolchange] ==== midiControlChange [source,text] @@ -8423,6 +8452,7 @@ Moves a controller on the open output port: volume is controller `7`, pan is `10 midiControlChange(1, 7, 40) ---- +[#midiinputcount] ==== midiInputCount [source,text] @@ -8441,6 +8471,7 @@ How many MIDI input ports this machine has. `0` when there are none, or when the *Since:* 3.00. *See also:* <> +[#midiinputname] ==== midiInputName [source,text] @@ -8467,6 +8498,7 @@ for i = 0, midiInputCount() - 1 do end ---- +[#midiisinputopen] ==== midiIsInputOpen [source,text] @@ -8485,6 +8517,7 @@ Whether an input port is open. *Since:* 3.00. *See also:* <> +[#midiisoutputopen] ==== midiIsOutputOpen [source,text] @@ -8503,6 +8536,7 @@ Whether an output port is open. Worth asking before a burst of messages, since e *Since:* 3.00. *See also:* <> +[#midinoteoff] ==== midiNoteOff [source,text] @@ -8522,6 +8556,7 @@ Releases a note on the open output port. A note left on sounds until something s *Since:* 3.00. *See also:* <> +[#midinoteon] ==== midiNoteOn [source,text] @@ -8550,6 +8585,7 @@ midiNoteOn(10, 49, 110) midiNoteOff(10, 49) ---- +[#midiopeninput] ==== midiOpenInput [source,text] @@ -8576,6 +8612,7 @@ if midiInputCount() > 0 then end ---- +[#midiopenoutput] ==== midiOpenOutput [source,text] @@ -8594,6 +8631,7 @@ Takes one output port for the sending calls. Whichever output port was open is c *Since:* 3.00. *See also:* <> +[#midioutputcount] ==== midiOutputCount [source,text] @@ -8612,6 +8650,7 @@ How many MIDI output ports this machine has. *Since:* 3.00. *See also:* <> +[#midioutputname] ==== midiOutputName [source,text] @@ -8630,6 +8669,7 @@ The name of an output port. *Since:* 3.00. *See also:* <> +[#midipitchbend] ==== midiPitchBend [source,text] @@ -8648,6 +8688,7 @@ Bends a channel. `8192` is the middle, meaning no bend; how far `0` and `16383` *Since:* 3.00. +[#midiprogramchange] ==== midiProgramChange [source,text] @@ -8666,6 +8707,7 @@ Chooses the sound a channel plays. The numbers are the receiving device's; on a *Since:* 3.00. +[#midirescan] ==== midiRescan [source,text] @@ -8684,6 +8726,7 @@ Looks for ports again, for a device plugged in while the game is running. An ope *Since:* 3.00. *See also:* <>, <> +[#midisend] ==== midiSend [source,text] @@ -8927,7 +8970,7 @@ for device = 0, mouseHowMany() - 1 do end ---- -[#mousegetposition] +[#mousegetpadding] ==== mouseGetPadding [source,text] @@ -8942,6 +8985,7 @@ Hypseus extension, always `0`, for the reason <> +[#mousegetposition] ==== mouseGetPosition [source,text] @@ -11034,7 +11078,7 @@ end overlayClear() ---- -Fills the whole overlay with the current background color. The background starts as transparent black, so with no `colorBackground` call this erases the overlay back to invisible and the video shows through. A partly opaque background dims the video; a fully opaque one hides it. Most games call it first thing in `onOverlayUpdate`. Any arguments are ignored. +Fills the whole overlay with the current background color. When the color is the same as the last clear's, only what was drawn since is filled again, so clearing an overlay that holds a few lines of text costs next to nothing whatever its size. The background starts as transparent black, so with no `colorBackground` call this erases the overlay back to invisible and the video shows through. A partly opaque background dims the video; a fully opaque one hides it. Most games call it first thing in `onOverlayUpdate`. Any arguments are ignored. *Since:* 1.x *See also:* <>, <> @@ -11329,7 +11373,7 @@ MARQUEE_X = overlayGetWidth() - MARGIN_X - MARQUEE_W CABINET_H = overlayGetHeight() - MARGIN_Y - CABINET_Y ---- -[#setoverlaylinearscale] +[#setoverlayfullalpha] ==== setOverlayFullAlpha [source,text] @@ -11343,6 +11387,7 @@ Hypseus extension, accepted and ignored. Hypseus blits its overlay with the alph *Since:* 3.00 +[#setoverlaylinearscale] ==== setOverlayLinearScale [source,text] @@ -12407,6 +12452,7 @@ Whole numbers come back as whole numbers. JSON has only one kind of number and Lua has two, so a score saved as `100` would otherwise return as `100.0` and print with a decimal point. +[#saveclear] ==== saveClear [source,text] @@ -12424,6 +12470,7 @@ Forgets everything and writes the empty save out. For a service menu's "reset hi *Since:* 3.00. +[#savedelete] ==== saveDelete [source,text] @@ -12441,6 +12488,7 @@ Removes one key. Deleting a key that was never there is not an error. *Since:* 3.00. +[#saveflush] ==== saveFlush [source,text] @@ -12459,6 +12507,7 @@ Writes the save out now rather than at the end of the frame. Use it after someth *Since:* 3.00. *See also:* <> +[#saveget] ==== saveGet [source,text] @@ -12493,6 +12542,7 @@ saveSet("highScores", best) saveFlush() ---- +[#savegetall] ==== saveGetAll [source,text] @@ -12518,6 +12568,7 @@ A copy of the whole save, for reading: dumping it while you are debugging, or wa debugPrint(require("inspect")(saveGetAll())) ---- +[#saveset] ==== saveSet [source,text] @@ -12537,6 +12588,7 @@ Keeps a value until the game is uninstalled. The write happens at the end of the *Since:* 3.00. *See also:* <>, <> +[#savesetall] ==== saveSetAll [source,text] @@ -13164,8 +13216,8 @@ Replaces the running script with the one the table describes. The current script function onInputPressed(what) if what == SWITCH_START1 then scriptExecute({ - SCRIPT = DIR .. "../Lair/Lair.singe", - VIDEO = DIR .. "../Lair/Lair.mp4", + SCRIPT = DIR .. "../MyGame/MyGame.singe", + VIDEO = DIR .. "../MyGame/MyGame.mp4", }) end end @@ -13205,6 +13257,35 @@ end Functions in the `singe*` namespace control the engine itself: the window, screenshots, the pause system, quitting and reloading, the audio delay used for lip sync, and a few paths and flags that come from the command line and `games.dat`. Nothing here takes a handle. Pause behavior is described in <> and the audio delay values in <>. +[#allowsocketcall] +==== allowSocketCall + +[source,text] +---- +allowed = allowSocketCall() +---- + +Hypseus extension, always `false` in Singe. Hypseus answers whether the user let it send data to a stats server, which its `-noserversend` turns off; the sending itself is commented out there. Singe sends nothing anywhere, so nothing is permitted and the answer never changes. It exists so a game that asks does not stop on a missing function. + +*Returns:* a boolean, always `false`. + +*Since:* 3.00 + +[#getuserstring] +==== getUserString + +[source,text] +---- +id = getUserString() +---- + +Hypseus extension, always an empty string. Despite the name it is not a string the user set: Hypseus answers the machine install ID it sends with network statistics, taken from the Windows `MachineGuid` or the Linux machine ID. Singe sends nothing anywhere and will not hand a game a fingerprint of the machine it is running on. + +*Returns:* a string, always empty. + +*Since:* 3.00 +*See also:* <> + [#singedisablepausekey] ==== singeDisablePauseKey @@ -13917,7 +13998,7 @@ Sets the title of the Singe window. Call it once at startup with the game's disp .Example [source,lua] ---- -singeSetGameName("Space Ace") +singeSetGameName("Asteroid Patrol") keyboardSetMode(MODE_NORMAL) hudFont = fontLoad(DIR .. "fonts/FreeSansBold.ttf", 18) ---- @@ -14843,7 +14924,21 @@ Sprites are 2D bitmaps loaded from disk with `spriteLoad`, or rendered from text *Sprite sheets.* Hypseus Singe extends the family with sprite sheets: `spriteLoadFrames` slices a single row strip of equal frames into an animation that is not playing, and `spriteDrawFrame` draws one chosen frame (1-based) without touching playback. A sheet is otherwise an ordinary animation, so `spriteSetFrame` (0-based), `spritePlay`, `spriteDraw`, `nodeSetSprite` and `emitterSetTexture` all accept it, and `spriteFrameWidth` and `spriteFrameHeight` report the unscaled size of one frame. The entries marked "Hypseus extension" exist so games written for Hypseus Singe run unchanged. -[#spritedraw] +[#drawtransparent] +==== drawTransparent + +[source,text] +---- +drawTransparent(boolean) +---- + +Hypseus extension, accepted and ignored. It decides whether Hypseus sets colour key 0 on the sprites loaded after it, which is how that engine carries transparency. Singe carries transparency in the alpha channel, as <> already describes, so there is no key to turn on or off. + +*Returns:* nothing. + +*Since:* 3.00 + +[#spriteblend] ==== spriteBlend [source,text] @@ -14857,6 +14952,7 @@ Hypseus extension, accepted and ignored. It is a workaround for that engine: Hyp *Since:* 3.00 +[#spritedraw] ==== spriteDraw [source,text] @@ -15770,7 +15866,7 @@ if srtLoad("subs/" .. language .. ".srt") then end ---- -[#srtposition] +[#srtloadtrack] ==== srtLoadTrack [source,text] @@ -15805,6 +15901,7 @@ end srtEnable(true) ---- +[#srtposition] ==== srtPosition [source,text] @@ -15988,6 +16085,7 @@ function onInputPressed(key) end ---- +[#statsisenabled] ==== statsIsEnabled [source,text] @@ -16066,6 +16164,7 @@ the game catches up rather than firing the twenty times it missed. Everything is forgotten when the script reloads, so a timer from the last run never fires into the next one. +[#timerafter] ==== timerAfter [source,text] @@ -16095,6 +16194,7 @@ timerAfter(1500, function() end) ---- +[#timercancel] ==== timerCancel [source,text] @@ -16112,6 +16212,7 @@ Stops a timer. Cancelling one that has already fired is not an error, so nothing *Since:* 3.00. +[#timerevery] ==== timerEvery [source,text] @@ -16146,6 +16247,7 @@ clock = timerEvery(1000, function() end) ---- +[#timerisactive] ==== timerIsActive [source,text] @@ -16181,6 +16283,7 @@ to sharp; `EASE_BACK_*` overshoots and comes back; `EASE_ELASTIC_*` springs; `EASE_BOUNCE_*` drops and bounces. Each comes in `_IN` (slow at the start), `_OUT` (slow at the end) and `_IN_OUT` (both). +[#tweencancel] ==== tweenCancel [source,text] @@ -16198,6 +16301,7 @@ Stops a tween where it is. Neither function is called again, and the value stays *Since:* 3.00. +[#tweenisactive] ==== tweenIsActive [source,text] @@ -16215,6 +16319,7 @@ Whether the tween is still running. *Since:* 3.00. +[#tweenvalue] ==== tweenValue [source,text] @@ -16956,6 +17061,8 @@ videoDraw(id, x, y, centered) Advances a video to the frame its clock calls for and draws that frame on the overlay. The clock follows the video's audio when it has any and wall time otherwise, so a call may advance zero, one or several frames; a video that reaches its end loops back to frame `0`. The five argument form stretches the frame into the rectangle from `(x, y)` to `(x2, y2)` inclusive, always with nearest-neighbor sampling and ignoring `videoScale`, `videoRotate` and `videoQuality`. The four argument form draws the frame at its native size transformed by `videoScale` and `videoRotate`, anchored at its top left corner or, when `centered` is `true`, at its center; the transformed frame is rebuilt only when the frame or the transform changes, and rotated corners are transparent. If the decoder has not produced a frame yet nothing is drawn. +A frame that is not scaled or turned by `videoScale` or `videoRotate`, drawn on the overlay where nothing has been drawn since an `overlayClear` to a transparent color -- a background video drawn first, say -- is drawn by the GPU instead, under the overlay, and costs next to nothing; anything drawn afterwards still goes over it. It is then scaled to the screen the way the overlay is, rather than nearest-neighbor into the overlay first. Everywhere else the frame is copied into the overlay on the CPU, which on a small machine costs several milliseconds a frame, so draw a video before anything that goes over it rather than after. + *Parameters:* * `id` -- video handle from `videoLoad`. @@ -18239,7 +18346,7 @@ function onKeyPressed(keysym, scancode) end ---- -[#onmousemoved] +[#onmidimessage] ==== onMidiMessage [source,text] @@ -18286,6 +18393,7 @@ function onMidiMessage(status, data1, data2, bytes) end ---- +[#onmousemoved] ==== onMouseMoved [source,text] @@ -18558,8 +18666,10 @@ real engine calls under <>; `mainBezelLoaded` and the seven `scoreBezel` calls are real engine calls under <>; and `getFValue`, `ratioGetX` and `ratioGetY` are real engine calls under <>; `joyMouseEnable` is a real engine call -under <>; and the whole `music` family is now a set of real -engine calls under <>. The six `spriteAnim` names below are +under <>; `allowSocketCall` and `getUserString` are real +engine calls under <>, and `drawTransparent` under <>; +and the whole `music` family is now a set of real engine calls under +<>. The six `spriteAnim` names below are plain aliases set after the legacy sprite wrapping, so they take whichever argument order the rest of the sprite family is taking: Hypseus's own (handle last) under `LEGACY_SPRITE_ARGS`, and Singe 3.00's (handle first) without it. diff --git a/docs/lessons/19-text.adoc b/docs/lessons/19-text.adoc index 2980ce11b..031c369ef 100644 --- a/docs/lessons/19-text.adoc +++ b/docs/lessons/19-text.adoc @@ -183,6 +183,14 @@ white, this is why. === The Trap: fontPrint Is Not Free +NOTE: Rendering text to a sprite for performance is deprecated. Singe 3.00's +`fontPrint` keeps every string it renders and only copies it again while the +string, font, quality, and colours stay the same, so printing a title every +frame now costs about what drawing a sprite of it does. The trap below is the +one earlier versions set, and the pattern still works; new games can just +`fontPrint`, and keep `fontToSprite` for text that has to be a sprite -- to be +measured, scaled, or turned. + Here is the thing the reference says plainly and everyone reads past. `fontPrint` does not draw a picture it made earlier. Every single call @@ -475,9 +483,10 @@ thing, several hundred lines away from the `fontUnload` that caused it. * Colour is not an argument. `colorForeground` is a setting; set it, then draw. * `fontQuality` picks how letters are rasterized, and `FONT_QUALITY_BLENDED` is the usual answer. -* `fontPrint` re-renders the string on every single call. -* `fontToSprite` renders once into a sprite, which `spriteDraw` then copies - cheaply and `spriteGetWidth` measures. +* `fontPrint` keeps what it renders, so printing the same text every frame is + cheap. +* `fontToSprite` renders into a sprite, which `spriteGetWidth` measures and the + `sprite*` calls can scale and turn. Using it only for speed is deprecated. * Measuring is how you centre or right align text, because nothing else can tell you how wide a string will be. * A sprite's colour is fixed when it is made, not when it is drawn. diff --git a/assets/Forge/Author.singe b/forge/Author.singe similarity index 99% rename from assets/Forge/Author.singe rename to forge/Author.singe index cca791539..7bbca7fa8 100644 --- a/assets/Forge/Author.singe +++ b/forge/Author.singe @@ -5255,41 +5255,80 @@ end -- ----- Timers, sounds, clips ---------------------------------------------------------------- +-- The framework's two countdowns, the timer and the joystick delay, arithmetic and all: each sums +-- os.clock() deltas as (sum + now) - last and compares the sum. One that subtracted two times +-- instead lands a hair either side of the limit on the frame it is due, which is a frame late for +-- the rest of the game (FORGE.md 14.19). A paused game's countdown holds, as the framework's does. +-- keys names the countdown's three fields on q. +local TIMER_KEYS = { limit = "timerLimit", secs = "timerSecs", last = "timerLast" } +local JOY_KEYS = { limit = "joyLimit", secs = "joySecs", last = "joyLast" } + + +local function countdownStart(q, keys, seconds) + q[keys.limit] = seconds + q[keys.secs] = 0 + q[keys.last] = os.clock() +end + + +local function countdownDue(q, keys) + if q[keys.limit] == nil then + return true + end + local now = os.clock() + + if q.bPause then + q[keys.last] = now + return false + end + if now ~= q[keys.last] then + q[keys.secs] = q[keys.secs] + now - q[keys.last] + q[keys.last] = now + end + if q[keys.secs] >= q[keys.limit] then + q[keys.limit] = nil + return true + end + + return false +end + + function Q.timerON(q, seconds) - q.timerLimit = seconds - q.timerFrom = authorTime() + countdownStart(q, TIMER_KEYS, seconds) end function Q.timerDue(q) - if q.timerLimit == nil then - return true - end - if authorTime() - q.timerFrom >= q.timerLimit then - q.timerLimit = nil - return true - end - - return false + return countdownDue(q, TIMER_KEYS) end function Q.joyDelayON(q, seconds) - q.joyLimit = seconds - q.joyFrom = authorTime() + countdownStart(q, JOY_KEYS, seconds) end function Q.joyDelayDue(q) - if q.joyLimit == nil then - return true - end - if authorTime() - q.joyFrom >= q.joyLimit then - q.joyLimit = nil - return true - end + return countdownDue(q, JOY_KEYS) +end - return false + +-- The framework's setLang: the disc's audio in the language the dips name, or the default when +-- that file is missing. readConfig calls it only when the saved config has a dip_Lang line, which +-- is exactly when the description's snapshot carries one, so the loops call it on that condition. +-- Beyond the language, the call itself holds the disc for a frame, and the traces see it. +function Q.setLang(q) + if (q.LangOpt == nil) or (q.snapshot.dips == nil) or (q.snapshot.dips.dip_Lang == nil) then + return + end + if q.dip_Lang + 1 > #q.LangOpt then + q.dip_Lang = 0 + end + if not discAudioSuffix(q.LangOpt[q.dip_Lang + 1][2]) then + debugPrint(q.LangOpt[q.dip_Lang + 1][1] .. " audio file unavailable, using default instead.") + discAudioSuffix("") + end end @@ -5311,13 +5350,19 @@ function Q.sound(q, name) end +-- The framework's 3.32b, the Hypseus library's copy, where it parts from 3.31c. +function Q.karis332(q) + return q.snapshot.framework == "3.32b" +end + + -- The secret level's combination on the attract screens: the framework's 3.32b asks for -- BUTTON4 and UP, the earlier versions for BUTTON2, BUTTON3, UP, and RIGHT. function Q.secretPressed(q) if not q.AllowSecret then return false end - if q.snapshot.framework == "3.32b" then + if Q.karis332(q) then return q.p1BUTTON4 and q.p1UP end @@ -7205,7 +7250,9 @@ function Q.doGameOver(q) if q.bGOAlt then Q.setupClip(q, q.offsetGameOverAlt, q.offsetGameOverAltend) q.bGOAlt = false - elseif q.currentFrame == q.offsetContinueend then + elseif (not Q.karis332(q)) and (q.currentFrame == q.offsetContinueend) then + -- 3.31c plays on from the continue clip when the game over follows it on the disc; + -- 3.32b always seeks. q.iFrameEnd = q.offsetGameOverend else Q.setupClip(q, q.offsetGameOver, q.offsetGameOverend) @@ -7384,7 +7431,7 @@ function Q.loadSprite(q, name, frames) if probe ~= nil then probe:close() if frames then - handle = spriteLoadFrames(path, frames) + handle = spriteLoadFrames(frames, path) else handle = spriteLoad(path) end @@ -7606,7 +7653,6 @@ function Q.shim(q) videoSetVolume = function() end, videoDraw = function() end, overlayClear = function() end, - colorForeground = function() end, autoSave = function() end, writeSave = function() end, loadSave = function() end, @@ -7614,7 +7660,13 @@ function Q.shim(q) cfgWritePath = function(name) return singeGetDataPath() .. "Cfg/" .. name end, singeGetScriptPath = function() return q.MYDIR .. "/" .. (q.snapshot.script or "game.singe") end, dofile = function(name) - -- A map-mode game's files are its own data and helpers: loaded into the shim. + -- A map-mode game's files are its own data and helpers: loaded into the shim. The + -- engine's own Framework.singe is not: the shim stands in for it, and loading it here + -- would wrap the shim's sprite calls a second time for a game that asks for the old + -- argument order, turning its y into a sprite handle. + if (type(name) == "string") and name:lower():match("^singe[/\\]framework%.singe$") then + return + end if q.loadsFiles then Q.runScript(q, Q.gameFile(q, name)) end @@ -7804,6 +7856,12 @@ AUTHOR.behaviours.qte = { step = function(instance) local q = instance.q + Q.dropDraws(q) + + -- What the game's own Lua queued to draw last frame: this loop has no look that draws it + -- (FORGE.md 15.4), and a queue nothing empties grows by every call, every frame. + Q.dropDraws(q) + q.currentFrame = discGetFrame() if q.gameflow == "vldp" then -- The framework's start: the title clip once through, a frame of init, the attract loop. @@ -7822,6 +7880,7 @@ AUTHOR.behaviours.qte = { if q.gameflow == "init" then q.gameflow = "running" q.lvlState = QTE.lvlSetup + Q.setLang(q) -- initJob's readConfig return end if q.currentLevel == QTE.levelIntro then @@ -10587,6 +10646,8 @@ AUTHOR.behaviours.kimmy = { step = function(instance) local q = instance.q + Q.dropDraws(q) + q.currentFrame = discGetFrame() if q.gameflow == "vldp" then if q.lvlState == QTE.lvlSetup then @@ -10607,6 +10668,7 @@ AUTHOR.behaviours.kimmy = { q.lvlState = QTE.lvlSetup q.iCoins, q.iScore, q.iScoreTemp, q.iBonus, q.iScene = 0, 0, 0, 0, 0 q.bShowCredits = true + Q.setLang(q) -- initJob's readConfig return end local level = q.currentLevel @@ -11733,6 +11795,8 @@ AUTHOR.behaviours.rdg = { step = function(instance) local q = instance.q + Q.dropDraws(q) + q.currentFrame = discGetFrame() if q.gameflow == "vldp" then -- The ancestor's start: the title clip with the inputs paused, then the init. @@ -12532,6 +12596,8 @@ AUTHOR.behaviours.timegal = { step = function(instance) local q = instance.q + Q.dropDraws(q) + q.currentFrame = discGetFrame() if q.gameflow == "vldp" then -- The ancestor's start: the title clip, then the init. @@ -13664,6 +13730,8 @@ AUTHOR.behaviours.sdq = { step = function(instance) local q = instance.q + Q.dropDraws(q) + q.currentFrame = discGetFrame() if q.gameflow == "vldp" then if q.lvlState == q.lvlSetup then diff --git a/assets/Forge/AuthorCompile.singe b/forge/AuthorCompile.singe similarity index 100% rename from assets/Forge/AuthorCompile.singe rename to forge/AuthorCompile.singe diff --git a/assets/Forge/Forge.rml b/forge/Forge.rml similarity index 100% rename from assets/Forge/Forge.rml rename to forge/Forge.rml diff --git a/assets/Forge/Forge.singe b/forge/Forge.singe similarity index 99% rename from assets/Forge/Forge.singe rename to forge/Forge.singe index ef70474c2..c512f7b77 100644 --- a/assets/Forge/Forge.singe +++ b/forge/Forge.singe @@ -4162,8 +4162,6 @@ function forgeKey(keysym, scancode) forgeBuild(singeGetDataPath() .. "preview.singe") elseif scancode == SCANCODE.X.value then forgeExport(forgeReleaseFolder()) - elseif scancode == SCANCODE.P.value then - forgePublish() elseif scancode == SCANCODE.U.value then forgeUndo() elseif scancode == SCANCODE.R.value then @@ -5094,7 +5092,12 @@ if not FORGE_LIBRARY then elseif (scancode == SCANCODE.ESCAPE.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then editorEscape() elseif (scancode == SCANCODE.P.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then - forgePlay() + -- P plays; Shift+P publishes, one step past X's export. + if (keyboardGetModifiers() & MODIFIER.SHIFT.value) ~= 0 then + forgePublish() + else + forgePlay() + end else forgeKey(keysym, scancode) end diff --git a/assets/Forge/games.dat b/forge/games.dat similarity index 100% rename from assets/Forge/games.dat rename to forge/games.dat diff --git a/assets/Forge/kit/LICENSE b/forge/kit/LICENSE similarity index 100% rename from assets/Forge/kit/LICENSE rename to forge/kit/LICENSE diff --git a/assets/Forge/kit/README.txt b/forge/kit/README.txt similarity index 100% rename from assets/Forge/kit/README.txt rename to forge/kit/README.txt diff --git a/assets/Forge/kit/click.wav b/forge/kit/click.wav similarity index 100% rename from assets/Forge/kit/click.wav rename to forge/kit/click.wav diff --git a/assets/Forge/kit/clip.mkv b/forge/kit/clip.mkv similarity index 100% rename from assets/Forge/kit/clip.mkv rename to forge/kit/clip.mkv diff --git a/assets/Forge/kit/coin.png b/forge/kit/coin.png similarity index 100% rename from assets/Forge/kit/coin.png rename to forge/kit/coin.png diff --git a/assets/Forge/kit/coin.wav b/forge/kit/coin.wav similarity index 100% rename from assets/Forge/kit/coin.wav rename to forge/kit/coin.wav diff --git a/assets/Forge/kit/crate.png b/forge/kit/crate.png similarity index 100% rename from assets/Forge/kit/crate.png rename to forge/kit/crate.png diff --git a/assets/Forge/kit/door.png b/forge/kit/door.png similarity index 100% rename from assets/Forge/kit/door.png rename to forge/kit/door.png diff --git a/assets/Forge/kit/door.wav b/forge/kit/door.wav similarity index 100% rename from assets/Forge/kit/door.wav rename to forge/kit/door.wav diff --git a/assets/Forge/kit/enemy.png b/forge/kit/enemy.png similarity index 100% rename from assets/Forge/kit/enemy.png rename to forge/kit/enemy.png diff --git a/assets/Forge/kit/fox.glb b/forge/kit/fox.glb similarity index 100% rename from assets/Forge/kit/fox.glb rename to forge/kit/fox.glb diff --git a/assets/Forge/kit/hall.png b/forge/kit/hall.png similarity index 100% rename from assets/Forge/kit/hall.png rename to forge/kit/hall.png diff --git a/assets/Forge/kit/hero.png b/forge/kit/hero.png similarity index 100% rename from assets/Forge/kit/hero.png rename to forge/kit/hero.png diff --git a/assets/Forge/kit/hit.wav b/forge/kit/hit.wav similarity index 100% rename from assets/Forge/kit/hit.wav rename to forge/kit/hit.wav diff --git a/assets/Forge/kit/jump.wav b/forge/kit/jump.wav similarity index 100% rename from assets/Forge/kit/jump.wav rename to forge/kit/jump.wav diff --git a/assets/Forge/kit/key.png b/forge/kit/key.png similarity index 100% rename from assets/Forge/kit/key.png rename to forge/kit/key.png diff --git a/assets/Forge/kit/loop.ogg b/forge/kit/loop.ogg similarity index 100% rename from assets/Forge/kit/loop.ogg rename to forge/kit/loop.ogg diff --git a/assets/Forge/kit/rock.png b/forge/kit/rock.png similarity index 100% rename from assets/Forge/kit/rock.png rename to forge/kit/rock.png diff --git a/assets/Forge/kit/ship.png b/forge/kit/ship.png similarity index 100% rename from assets/Forge/kit/ship.png rename to forge/kit/ship.png diff --git a/assets/Forge/kit/shot.png b/forge/kit/shot.png similarity index 100% rename from assets/Forge/kit/shot.png rename to forge/kit/shot.png diff --git a/assets/Forge/kit/shot.wav b/forge/kit/shot.wav similarity index 100% rename from assets/Forge/kit/shot.wav rename to forge/kit/shot.wav diff --git a/assets/Forge/kit/spike.png b/forge/kit/spike.png similarity index 100% rename from assets/Forge/kit/spike.png rename to forge/kit/spike.png diff --git a/assets/Forge/kit/target.png b/forge/kit/target.png similarity index 100% rename from assets/Forge/kit/target.png rename to forge/kit/target.png diff --git a/assets/Forge/kit/tiles.png b/forge/kit/tiles.png similarity index 100% rename from assets/Forge/kit/tiles.png rename to forge/kit/tiles.png diff --git a/assets/Forge/kit/yard.png b/forge/kit/yard.png similarity index 100% rename from assets/Forge/kit/yard.png rename to forge/kit/yard.png diff --git a/assets/Forge/tutorials/01-tenMinutes.forge b/forge/tutorials/01-tenMinutes.forge similarity index 100% rename from assets/Forge/tutorials/01-tenMinutes.forge rename to forge/tutorials/01-tenMinutes.forge diff --git a/assets/Forge/tutorials/02-platformer.forge b/forge/tutorials/02-platformer.forge similarity index 100% rename from assets/Forge/tutorials/02-platformer.forge rename to forge/tutorials/02-platformer.forge diff --git a/assets/Forge/tutorials/03-sprites.forge b/forge/tutorials/03-sprites.forge similarity index 100% rename from assets/Forge/tutorials/03-sprites.forge rename to forge/tutorials/03-sprites.forge diff --git a/assets/Forge/tutorials/04-shmup.forge b/forge/tutorials/04-shmup.forge similarity index 100% rename from assets/Forge/tutorials/04-shmup.forge rename to forge/tutorials/04-shmup.forge diff --git a/assets/Forge/tutorials/05-qte.forge b/forge/tutorials/05-qte.forge similarity index 100% rename from assets/Forge/tutorials/05-qte.forge rename to forge/tutorials/05-qte.forge diff --git a/assets/Forge/tutorials/06-gun.forge b/forge/tutorials/06-gun.forge similarity index 100% rename from assets/Forge/tutorials/06-gun.forge rename to forge/tutorials/06-gun.forge diff --git a/assets/Forge/tutorials/07-adventure.forge b/forge/tutorials/07-adventure.forge similarity index 100% rename from assets/Forge/tutorials/07-adventure.forge rename to forge/tutorials/07-adventure.forge diff --git a/assets/Forge/tutorials/08-scene.forge b/forge/tutorials/08-scene.forge similarity index 100% rename from assets/Forge/tutorials/08-scene.forge rename to forge/tutorials/08-scene.forge diff --git a/assets/Forge/tutorials/09-rail.forge b/forge/tutorials/09-rail.forge similarity index 100% rename from assets/Forge/tutorials/09-rail.forge rename to forge/tutorials/09-rail.forge diff --git a/assets/Forge/tutorials/10-release.forge b/forge/tutorials/10-release.forge similarity index 100% rename from assets/Forge/tutorials/10-release.forge rename to forge/tutorials/10-release.forge diff --git a/assets/Dragon.jpeg b/media/Dragon.jpeg similarity index 100% rename from assets/Dragon.jpeg rename to media/Dragon.jpeg diff --git a/assets/Dragon.mtl b/media/Dragon.mtl similarity index 100% rename from assets/Dragon.mtl rename to media/Dragon.mtl diff --git a/assets/Dragon.obj b/media/Dragon.obj similarity index 100% rename from assets/Dragon.obj rename to media/Dragon.obj diff --git a/assets/Dragon.svg b/media/Dragon.svg similarity index 100% rename from assets/Dragon.svg rename to media/Dragon.svg diff --git a/assets/DragonModel.mtl b/media/DragonModel.mtl similarity index 100% rename from assets/DragonModel.mtl rename to media/DragonModel.mtl diff --git a/assets/DragonModel.obj b/media/DragonModel.obj similarity index 52% rename from assets/DragonModel.obj rename to media/DragonModel.obj index 251da2988..00a31e3f6 100644 --- a/assets/DragonModel.obj +++ b/media/DragonModel.obj @@ -9,19 +9,13 @@ v 1.95500 5.78000 -0.38000 v 2.06500 5.67000 -0.38000 v 1.43500 5.02000 -0.38000 v 1.47500 4.98000 -0.38000 -v 2.50500 5.19000 -0.38000 -v 3.02500 4.76000 -0.38000 -v 3.06500 4.80000 -0.38000 -v 3.32500 5.32000 -0.38000 -v 3.25500 5.39000 -0.38000 -v 3.20500 5.37000 -0.38000 -v 2.76500 5.47000 -0.38000 -v 2.65500 5.58000 -0.38000 -v 2.76500 5.69000 -0.38000 +v 2.48500 5.19000 -0.38000 +v 2.52500 5.23000 -0.38000 +v 2.24500 5.49000 -0.38000 +v 2.35500 5.61000 -0.38000 v 3.09500 5.76000 -0.38000 v 3.32500 5.49000 -0.38000 -v 3.36500 5.53000 -0.38000 -v 3.55500 5.92000 -0.38000 +v 3.55500 5.91000 -0.38000 v 3.20500 6.40000 -0.38000 v 3.16500 6.29000 -0.38000 v 3.05500 6.18000 -0.38000 @@ -36,21 +30,15 @@ v 1.33504 6.11086 0.00000 v 1.91504 5.73086 0.00000 v 1.97660 5.66931 0.00000 v 1.34660 5.01931 0.00000 -v 1.45440 4.91150 0.00000 -v 2.48807 5.12225 0.00000 -v 3.02902 4.67493 0.00000 -v 3.11678 4.76268 0.00000 -v 3.40166 5.33244 0.00000 -v 3.27017 5.46392 0.00000 -v 3.19982 5.43578 0.00000 -v 2.79669 5.52740 0.00000 -v 2.74410 5.58000 0.00000 -v 2.79634 5.63225 0.00000 -v 3.07137 5.69059 0.00000 -v 3.32129 5.39720 0.00000 -v 3.41701 5.49292 0.00000 -v 3.62824 5.92649 0.00000 -v 3.18622 6.53269 0.00000 +v 1.45451 4.91139 0.00000 +v 2.51624 5.13215 0.00000 +v 2.61578 5.23168 0.00000 +v 2.33374 5.49357 0.00000 +v 2.38760 5.55233 0.00000 +v 3.07112 5.69088 0.00000 +v 3.33634 5.37954 0.00000 +v 3.62926 5.91443 0.00000 +v 3.18670 6.53401 0.00000 v 3.11057 6.32466 0.00000 v 3.03094 6.24504 0.00000 v 2.67362 6.27252 0.00000 @@ -65,19 +53,13 @@ v 1.95500 5.78000 0.38000 v 2.06500 5.67000 0.38000 v 1.43500 5.02000 0.38000 v 1.47500 4.98000 0.38000 -v 2.50500 5.19000 0.38000 -v 3.02500 4.76000 0.38000 -v 3.06500 4.80000 0.38000 -v 3.32500 5.32000 0.38000 -v 3.25500 5.39000 0.38000 -v 3.20500 5.37000 0.38000 -v 2.76500 5.47000 0.38000 -v 2.65500 5.58000 0.38000 -v 2.76500 5.69000 0.38000 +v 2.48500 5.19000 0.38000 +v 2.52500 5.23000 0.38000 +v 2.24500 5.49000 0.38000 +v 2.35500 5.61000 0.38000 v 3.09500 5.76000 0.38000 v 3.32500 5.49000 0.38000 -v 3.36500 5.53000 0.38000 -v 3.55500 5.92000 0.38000 +v 3.55500 5.91000 0.38000 v 3.20500 6.40000 0.38000 v 3.16500 6.29000 0.38000 v 3.05500 6.18000 0.38000 @@ -199,24 +181,6 @@ v 2.49750 5.22880 -0.39000 v 2.04140 5.58350 -0.38000 v 1.48390 5.01340 -0.38000 v 2.49750 5.22880 -0.38000 -v 3.29410 5.31780 0.38000 -v 2.04850 5.59590 0.38000 -v 3.02810 4.79770 0.38000 -v 3.29410 5.31780 0.39000 -v 2.04850 5.59590 0.39000 -v 3.02810 4.79770 0.39000 -v 3.24975 5.29696 0.42000 -v 2.12562 5.55849 0.42000 -v 3.01829 4.84439 0.42000 -v 3.24975 5.29696 -0.42000 -v 2.12562 5.55849 -0.42000 -v 3.01829 4.84439 -0.42000 -v 3.29410 5.31780 -0.39000 -v 2.04850 5.59590 -0.39000 -v 3.02810 4.79770 -0.39000 -v 3.29410 5.31780 -0.38000 -v 2.04850 5.59590 -0.38000 -v 3.02810 4.79770 -0.38000 v 0.14500 5.65000 -0.32000 v 0.01500 4.36000 -0.32000 v 0.06500 4.32000 -0.32000 @@ -793,6 +757,45 @@ v -4.12680 2.91050 -0.22000 v -3.71260 2.17180 -0.22000 v -3.11790 2.83140 -0.22000 v -2.91480 3.84710 -0.22000 +v 2.00500 5.60000 -0.30000 +v 2.06500 5.54000 -0.30000 +v 3.02500 4.76000 -0.30000 +v 3.06500 4.80000 -0.30000 +v 3.32500 5.32000 -0.30000 +v 3.28500 5.36000 -0.30000 +v 2.04500 5.64000 -0.30000 +v 1.93288 5.60000 0.00000 +v 2.03080 5.50208 0.00000 +v 3.02854 4.69141 0.00000 +v 3.10691 4.76979 0.00000 +v 3.38705 5.33007 0.00000 +v 3.31063 5.40650 0.00000 +v 2.02881 5.69594 0.00000 +v 2.00500 5.60000 0.30000 +v 2.06500 5.54000 0.30000 +v 3.02500 4.76000 0.30000 +v 3.06500 4.80000 0.30000 +v 3.32500 5.32000 0.30000 +v 3.28500 5.36000 0.30000 +v 2.04500 5.64000 0.30000 +v 3.29410 5.31780 0.30000 +v 2.04850 5.59590 0.30000 +v 3.02810 4.79770 0.30000 +v 3.29410 5.31780 0.31000 +v 2.04850 5.59590 0.31000 +v 3.02810 4.79770 0.31000 +v 3.24975 5.29696 0.34000 +v 2.12562 5.55849 0.34000 +v 3.01829 4.84439 0.34000 +v 3.24975 5.29696 -0.34000 +v 2.12562 5.55849 -0.34000 +v 3.01829 4.84439 -0.34000 +v 3.29410 5.31780 -0.31000 +v 2.04850 5.59590 -0.31000 +v 3.02810 4.79770 -0.31000 +v 3.29410 5.31780 -0.30000 +v 2.04850 5.59590 -0.30000 +v 3.02810 4.79770 -0.30000 v 3.54500 1.01000 -0.51000 v 2.45500 0.95000 -0.51000 v 2.41500 0.91000 -0.51000 @@ -1254,9 +1257,9 @@ vn 0.0350 0.7746 -0.6315 vn -0.3625 -0.6867 -0.6301 vn 0.0358 -0.7308 -0.6816 vn -0.4557 -0.8750 -0.1636 -vn 0.1971 -0.9666 -0.1636 +vn 0.2008 -0.9659 -0.1636 vn -0.4557 -0.8750 0.1636 -vn 0.1971 -0.9666 0.1636 +vn 0.2008 -0.9659 0.1636 vn 0.1935 0.9674 0.1636 vn 0.6059 -0.7955 0.0000 vn 0.0000 1.0000 0.0000 @@ -1277,32 +1280,24 @@ vn -0.5270 0.5189 -0.6731 vn 0.1555 -0.7221 -0.6741 vn 0.4341 0.5582 -0.7071 vn -0.5406 -0.8252 -0.1636 -vn -0.6287 -0.7603 -0.1636 vn 0.6976 -0.6976 -0.1636 -vn 0.8824 -0.4412 -0.1636 -vn 0.6976 0.6976 -0.1636 -vn -0.3664 0.9160 -0.1636 -vn 0.2186 0.9620 -0.1635 -vn 0.2047 -0.9651 -0.1635 +vn 0.6713 0.7229 -0.1636 +vn 0.7272 -0.6666 -0.1636 +vn 0.1960 -0.9669 -0.1636 vn -0.7510 -0.6397 -0.1636 -vn 0.6976 -0.6976 -0.1635 -vn 0.8869 -0.4321 -0.1636 -vn 0.7971 0.5812 -0.1636 +vn 0.8653 -0.4738 -0.1636 +vn 0.8028 0.5734 -0.1636 vn -0.9271 0.3371 -0.1635 vn 0.0757 0.9836 -0.1636 vn 0.1935 0.9674 -0.1636 vn 0.0418 0.9856 -0.1636 -vn -0.6287 -0.7603 0.1636 vn 0.6976 -0.6976 0.1636 -vn 0.8824 -0.4412 0.1636 -vn 0.6976 0.6976 0.1636 -vn -0.3664 0.9160 0.1636 -vn 0.2186 0.9620 0.1635 -vn 0.2047 -0.9651 0.1635 +vn 0.6713 0.7229 0.1636 +vn 0.7272 -0.6666 0.1636 +vn 0.1960 -0.9669 0.1636 vn -0.7510 -0.6397 0.1636 -vn 0.6976 -0.6976 0.1635 -vn 0.8869 -0.4321 0.1636 -vn 0.7971 0.5812 0.1636 +vn 0.8653 -0.4738 0.1636 +vn 0.8028 0.5734 0.1636 vn -0.9271 0.3371 0.1635 vn -0.6976 0.6976 0.1636 vn 0.0757 0.9836 0.1636 @@ -1334,15 +1329,6 @@ vn 0.6324 -0.3162 -0.7071 vn 0.5891 0.4091 -0.6969 vn -0.7157 0.0467 -0.6969 vn -0.6033 -0.3688 -0.7071 -vn 0.2179 0.9760 0.0000 -vn -0.6317 -0.7752 0.0000 -vn 0.8903 -0.4553 0.0000 -vn 0.1712 0.7520 0.6365 -vn -0.4846 -0.6000 0.6365 -vn 0.6296 -0.3220 0.7070 -vn 0.1712 0.7520 -0.6365 -vn -0.4846 -0.6000 -0.6365 -vn 0.6296 -0.3220 -0.7070 vn -0.9811 0.0989 -0.1664 vn -0.6160 -0.7700 -0.1664 vn 0.7506 -0.6394 -0.1664 @@ -1651,6 +1637,29 @@ vn -0.6168 -0.3458 -0.7071 vn 0.5252 -0.4735 -0.7071 vn 0.6934 -0.1387 -0.7071 vn -0.4323 0.5595 -0.7071 +vn -0.6971 -0.6971 -0.1676 +vn -0.6971 -0.6971 0.1676 +vn -0.6217 -0.7651 -0.1676 +vn 0.6971 -0.6971 -0.1676 +vn 0.8818 -0.4409 -0.1676 +vn 0.6971 0.6971 -0.1676 +vn 0.2171 0.9616 -0.1676 +vn -0.6217 -0.7651 0.1676 +vn 0.6971 -0.6971 0.1676 +vn 0.8818 -0.4409 0.1676 +vn 0.6971 0.6971 0.1676 +vn 0.2171 0.9616 0.1676 +vn 0.2179 0.9760 0.0000 +vn -0.6317 -0.7752 0.0000 +vn 0.8903 -0.4553 0.0000 +vn 0.1712 0.7520 0.6365 +vn -0.4846 -0.6000 0.6365 +vn 0.6296 -0.3220 0.7070 +vn 0.1712 0.7520 -0.6365 +vn -0.4846 -0.6000 -0.6365 +vn 0.6296 -0.3220 -0.7070 +vn -0.6971 0.6971 -0.1676 +vn -0.6971 0.6971 0.1676 vn -0.0541 0.9832 -0.1745 vn -0.4885 -0.8549 -0.1746 vn 0.6963 -0.6963 -0.1745 @@ -1826,1330 +1835,1334 @@ vn 0.1618 -0.6909 0.7047 vn 0.2253 -0.7025 0.6751 o head usemtl glass -f 1//1 2//1 30//1 29//1 -f 6//2 7//2 35//2 34//2 -f 26//3 27//3 55//3 54//3 -f 28//4 1//4 29//4 56//4 -f 32//5 33//5 61//5 60//5 -f 33//6 34//6 62//6 61//6 -f 34//7 35//7 63//7 62//7 -f 35//6 36//6 64//6 63//6 -f 54//8 55//8 83//8 82//8 -f 1//9 28//9 27//9 -f 83//10 84//10 57//10 -f 1//9 27//9 26//9 -f 82//10 83//10 57//10 -f 1//9 26//9 25//9 -f 81//10 82//10 57//10 +f 1//1 2//1 24//1 23//1 +f 6//2 7//2 29//2 28//2 +f 20//3 21//3 43//3 42//3 +f 22//4 1//4 23//4 44//4 +f 26//5 27//5 49//5 48//5 +f 27//6 28//6 50//6 49//6 +f 28//7 29//7 51//7 50//7 +f 29//6 30//6 52//6 51//6 +f 42//8 43//8 65//8 64//8 +f 1//9 22//9 21//9 +f 65//10 66//10 45//10 +f 1//9 21//9 20//9 +f 64//10 65//10 45//10 +f 1//9 20//9 19//9 +f 63//10 64//10 45//10 f 3//9 2//9 1//9 -f 57//10 58//10 59//10 -f 85//9 88//9 87//9 -f 95//10 96//10 93//10 -f 87//9 86//9 85//9 -f 93//10 94//10 95//10 -f 85//11 86//11 90//11 89//11 -f 86//12 87//12 91//12 90//12 -f 87//13 88//13 92//13 91//13 -f 88//14 85//14 89//14 92//14 -f 89//15 90//15 94//15 93//15 -f 90//16 91//16 95//16 94//16 -f 91//17 92//17 96//17 95//17 -f 92//18 89//18 93//18 96//18 -f 97//9 100//9 99//9 -f 107//10 108//10 105//10 -f 99//9 98//9 97//9 -f 105//10 106//10 107//10 -f 97//19 98//19 102//19 101//19 -f 98//20 99//20 103//20 102//20 -f 99//21 100//21 104//21 103//21 -f 100//22 97//22 101//22 104//22 -f 101//11 102//11 106//11 105//11 -f 102//12 103//12 107//12 106//12 -f 103//13 104//13 108//13 107//13 -f 104//14 101//14 105//14 108//14 +f 45//10 46//10 47//10 +f 67//9 70//9 69//9 +f 77//10 78//10 75//10 +f 69//9 68//9 67//9 +f 75//10 76//10 77//10 +f 67//11 68//11 72//11 71//11 +f 68//12 69//12 73//12 72//12 +f 69//13 70//13 74//13 73//13 +f 70//14 67//14 71//14 74//14 +f 71//15 72//15 76//15 75//15 +f 72//16 73//16 77//16 76//16 +f 73//17 74//17 78//17 77//17 +f 74//18 71//18 75//18 78//18 +f 79//9 82//9 81//9 +f 89//10 90//10 87//10 +f 81//9 80//9 79//9 +f 87//10 88//10 89//10 +f 79//19 80//19 84//19 83//19 +f 80//20 81//20 85//20 84//20 +f 81//21 82//21 86//21 85//21 +f 82//22 79//22 83//22 86//22 +f 83//11 84//11 88//11 87//11 +f 84//12 85//12 89//12 88//12 +f 85//13 86//13 90//13 89//13 +f 86//14 83//14 87//14 90//14 usemtl dark -f 2//23 3//23 31//23 30//23 -f 3//1 4//1 32//1 31//1 -f 7//1 8//1 36//1 35//1 -f 8//24 9//24 37//24 36//24 -f 29//6 30//6 58//6 57//6 -f 30//25 31//25 59//25 58//25 -f 36//26 37//26 65//26 64//26 -f 53//27 54//27 82//27 81//27 -f 1//9 25//9 21//9 -f 77//10 81//10 57//10 -f 15//9 9//9 8//9 -f 64//10 65//10 71//10 -f 15//9 8//9 7//9 -f 63//10 64//10 71//10 -f 15//9 7//9 6//9 -f 62//10 63//10 71//10 +f 2//23 3//23 25//23 24//23 +f 3//1 4//1 26//1 25//1 +f 7//1 8//1 30//1 29//1 +f 8//24 9//24 31//24 30//24 +f 23//6 24//6 46//6 45//6 +f 24//25 25//25 47//25 46//25 +f 30//26 31//26 53//26 52//26 +f 41//27 42//27 64//27 63//27 +f 1//9 19//9 15//9 +f 59//10 63//10 45//10 +f 11//9 9//9 8//9 +f 52//10 53//10 55//10 +f 12//9 11//9 8//9 +f 52//10 55//10 56//10 +f 12//9 8//9 7//9 +f 51//10 52//10 56//10 +f 12//9 7//9 6//9 +f 50//10 51//10 56//10 +f 147//9 146//9 145//9 +f 151//10 152//10 153//10 +f 145//28 146//28 149//28 148//28 +f 146//29 147//29 150//29 149//29 +f 147//30 145//30 148//30 150//30 +f 148//31 149//31 152//31 151//31 +f 149//32 150//32 153//32 152//32 +f 150//33 148//33 151//33 153//33 +f 156//9 155//9 154//9 +f 160//10 161//10 162//10 +f 154//34 155//34 158//34 157//34 +f 155//35 156//35 159//35 158//35 +f 156//36 154//36 157//36 159//36 +f 157//28 158//28 161//28 160//28 +f 158//29 159//29 162//29 161//29 +f 159//30 157//30 160//30 162//30 f 165//9 164//9 163//9 f 169//10 170//10 171//10 -f 163//28 164//28 167//28 166//28 -f 164//29 165//29 168//29 167//29 -f 165//30 163//30 166//30 168//30 -f 166//31 167//31 170//31 169//31 -f 167//32 168//32 171//32 170//32 -f 168//33 166//33 169//33 171//33 +f 163//37 164//37 167//37 166//37 +f 164//38 165//38 168//38 167//38 +f 165//39 163//39 166//39 168//39 +f 166//40 167//40 170//40 169//40 +f 167//41 168//41 171//41 170//41 +f 168//42 166//42 169//42 171//42 f 174//9 173//9 172//9 f 178//10 179//10 180//10 -f 172//34 173//34 176//34 175//34 -f 173//35 174//35 177//35 176//35 -f 174//36 172//36 175//36 177//36 -f 175//28 176//28 179//28 178//28 -f 176//29 177//29 180//29 179//29 -f 177//30 175//30 178//30 180//30 -f 183//9 182//9 181//9 -f 187//10 188//10 189//10 -f 181//37 182//37 185//37 184//37 -f 182//38 183//38 186//38 185//38 -f 183//39 181//39 184//39 186//39 -f 184//40 185//40 188//40 187//40 -f 185//41 186//41 189//41 188//41 -f 186//42 184//42 187//42 189//42 -f 192//9 191//9 190//9 -f 196//10 197//10 198//10 -f 190//43 191//43 194//43 193//43 -f 191//44 192//44 195//44 194//44 -f 192//45 190//45 193//45 195//45 -f 193//37 194//37 197//37 196//37 -f 194//38 195//38 198//38 197//38 -f 195//39 193//39 196//39 198//39 +f 172//43 173//43 176//43 175//43 +f 173//44 174//44 177//44 176//44 +f 174//45 172//45 175//45 177//45 +f 175//37 176//37 179//37 178//37 +f 176//38 177//38 180//38 179//38 +f 177//39 175//39 178//39 180//39 usemtl champagne -f 4//46 5//46 33//46 32//46 -f 5//1 6//1 34//1 33//1 -f 9//47 10//47 38//47 37//47 -f 10//48 11//48 39//48 38//48 -f 11//49 12//49 40//49 39//49 -f 12//50 13//50 41//50 40//50 -f 13//51 14//51 42//51 41//51 -f 14//52 15//52 43//52 42//52 -f 15//50 16//50 44//50 43//50 -f 16//48 17//48 45//48 44//48 -f 17//53 18//53 46//53 45//53 -f 18//54 19//54 47//54 46//54 -f 19//55 20//55 48//55 47//55 -f 20//56 21//56 49//56 48//56 -f 21//57 22//57 50//57 49//57 -f 22//58 23//58 51//58 50//58 -f 23//4 24//4 52//4 51//4 -f 24//59 25//59 53//59 52//59 -f 25//60 26//60 54//60 53//60 -f 27//61 28//61 56//61 55//61 -f 31//6 32//6 60//6 59//6 -f 37//62 38//62 66//62 65//62 -f 38//63 39//63 67//63 66//63 -f 39//64 40//64 68//64 67//64 -f 40//65 41//65 69//65 68//65 -f 41//66 42//66 70//66 69//66 -f 42//67 43//67 71//67 70//67 -f 43//65 44//65 72//65 71//65 -f 44//63 45//63 73//63 72//63 -f 45//68 46//68 74//68 73//68 -f 46//69 47//69 75//69 74//69 -f 47//70 48//70 76//70 75//70 -f 48//71 49//71 77//71 76//71 -f 49//72 50//72 78//72 77//72 -f 50//73 51//73 79//73 78//73 -f 51//74 52//74 80//74 79//74 -f 52//75 53//75 81//75 80//75 -f 55//76 56//76 84//76 83//76 -f 56//74 29//74 57//74 84//74 -f 23//9 22//9 21//9 -f 77//10 78//10 79//10 -f 24//9 23//9 21//9 -f 77//10 79//10 80//10 -f 25//9 24//9 21//9 -f 77//10 80//10 81//10 -f 21//9 20//9 19//9 -f 75//10 76//10 77//10 -f 21//9 19//9 18//9 -f 74//10 75//10 77//10 -f 1//9 21//9 18//9 -f 74//10 77//10 57//10 -f 1//9 18//9 17//9 -f 73//10 74//10 57//10 -f 1//9 17//9 16//9 -f 72//10 73//10 57//10 -f 1//9 16//9 15//9 -f 71//10 72//10 57//10 -f 14//9 13//9 12//9 -f 68//10 69//10 70//10 -f 15//9 14//9 12//9 -f 68//10 70//10 71//10 -f 15//9 12//9 11//9 -f 67//10 68//10 71//10 -f 15//9 11//9 10//9 -f 66//10 67//10 71//10 -f 15//9 10//9 9//9 -f 65//10 66//10 71//10 -f 15//9 6//9 5//9 -f 61//10 62//10 71//10 -f 15//9 5//9 4//9 -f 60//10 61//10 71//10 -f 15//9 4//9 3//9 -f 59//10 60//10 71//10 -f 1//9 15//9 3//9 -f 59//10 71//10 57//10 -f 109//9 113//9 112//9 -f 122//10 123//10 119//10 -f 109//9 112//9 111//9 -f 121//10 122//10 119//10 -f 111//9 110//9 109//9 -f 119//10 120//10 121//10 -f 109//77 110//77 115//77 114//77 -f 110//78 111//78 116//78 115//78 -f 111//79 112//79 117//79 116//79 -f 112//80 113//80 118//80 117//80 -f 113//81 109//81 114//81 118//81 -f 114//82 115//82 120//82 119//82 -f 115//83 116//83 121//83 120//83 -f 116//84 117//84 122//84 121//84 -f 117//85 118//85 123//85 122//85 -f 118//86 114//86 119//86 123//86 -f 124//9 128//9 127//9 -f 137//10 138//10 134//10 -f 124//9 127//9 126//9 -f 136//10 137//10 134//10 -f 126//9 125//9 124//9 -f 134//10 135//10 136//10 -f 124//87 125//87 130//87 129//87 -f 125//88 126//88 131//88 130//88 -f 126//89 127//89 132//89 131//89 -f 127//90 128//90 133//90 132//90 -f 128//91 124//91 129//91 133//91 -f 129//77 130//77 135//77 134//77 -f 130//78 131//78 136//78 135//78 -f 131//79 132//79 137//79 136//79 -f 132//80 133//80 138//80 137//80 -f 133//81 129//81 134//81 138//81 -f 139//9 142//9 141//9 -f 149//10 150//10 147//10 -f 141//9 140//9 139//9 -f 147//10 148//10 149//10 -f 139//92 140//92 144//92 143//92 -f 140//93 141//93 145//93 144//93 -f 141//94 142//94 146//94 145//94 -f 142//95 139//95 143//95 146//95 -f 143//96 144//96 148//96 147//96 -f 144//97 145//97 149//97 148//97 -f 145//98 146//98 150//98 149//98 -f 146//99 143//99 147//99 150//99 -f 151//9 154//9 153//9 -f 161//10 162//10 159//10 -f 153//9 152//9 151//9 -f 159//10 160//10 161//10 -f 151//100 152//100 156//100 155//100 -f 152//101 153//101 157//101 156//101 -f 153//102 154//102 158//102 157//102 -f 154//103 151//103 155//103 158//103 -f 155//92 156//92 160//92 159//92 -f 156//93 157//93 161//93 160//93 -f 157//94 158//94 162//94 161//94 -f 158//95 155//95 159//95 162//95 -f 201//9 200//9 199//9 -f 205//10 206//10 207//10 -f 199//104 200//104 203//104 202//104 -f 200//105 201//105 204//105 203//105 -f 201//106 199//106 202//106 204//106 -f 202//107 203//107 206//107 205//107 -f 203//108 204//108 207//108 206//108 -f 204//109 202//109 205//109 207//109 -f 210//9 209//9 208//9 -f 214//10 215//10 216//10 -f 208//110 209//110 212//110 211//110 -f 209//111 210//111 213//111 212//111 -f 210//112 208//112 211//112 213//112 -f 211//104 212//104 215//104 214//104 -f 212//105 213//105 216//105 215//105 -f 213//106 211//106 214//106 216//106 +f 4//46 5//46 27//46 26//46 +f 5//1 6//1 28//1 27//1 +f 9//47 10//47 32//47 31//47 +f 10//48 11//48 33//48 32//48 +f 11//49 12//49 34//49 33//49 +f 12//50 13//50 35//50 34//50 +f 13//51 14//51 36//51 35//51 +f 14//52 15//52 37//52 36//52 +f 15//53 16//53 38//53 37//53 +f 16//54 17//54 39//54 38//54 +f 17//4 18//4 40//4 39//4 +f 18//55 19//55 41//55 40//55 +f 19//56 20//56 42//56 41//56 +f 21//57 22//57 44//57 43//57 +f 25//6 26//6 48//6 47//6 +f 31//58 32//58 54//58 53//58 +f 32//59 33//59 55//59 54//59 +f 33//60 34//60 56//60 55//60 +f 34//61 35//61 57//61 56//61 +f 35//62 36//62 58//62 57//62 +f 36//63 37//63 59//63 58//63 +f 37//64 38//64 60//64 59//64 +f 38//65 39//65 61//65 60//65 +f 39//66 40//66 62//66 61//66 +f 40//67 41//67 63//67 62//67 +f 43//68 44//68 66//68 65//68 +f 44//66 23//66 45//66 66//66 +f 17//9 16//9 15//9 +f 59//10 60//10 61//10 +f 18//9 17//9 15//9 +f 59//10 61//10 62//10 +f 19//9 18//9 15//9 +f 59//10 62//10 63//10 +f 15//9 14//9 13//9 +f 57//10 58//10 59//10 +f 1//9 15//9 13//9 +f 57//10 59//10 45//10 +f 11//9 10//9 9//9 +f 53//10 54//10 55//10 +f 13//9 12//9 6//9 +f 50//10 56//10 57//10 +f 13//9 6//9 5//9 +f 49//10 50//10 57//10 +f 13//9 5//9 4//9 +f 48//10 49//10 57//10 +f 13//9 4//9 3//9 +f 47//10 48//10 57//10 +f 1//9 13//9 3//9 +f 47//10 57//10 45//10 +f 91//9 95//9 94//9 +f 104//10 105//10 101//10 +f 91//9 94//9 93//9 +f 103//10 104//10 101//10 +f 93//9 92//9 91//9 +f 101//10 102//10 103//10 +f 91//69 92//69 97//69 96//69 +f 92//70 93//70 98//70 97//70 +f 93//71 94//71 99//71 98//71 +f 94//72 95//72 100//72 99//72 +f 95//73 91//73 96//73 100//73 +f 96//74 97//74 102//74 101//74 +f 97//75 98//75 103//75 102//75 +f 98//76 99//76 104//76 103//76 +f 99//77 100//77 105//77 104//77 +f 100//78 96//78 101//78 105//78 +f 106//9 110//9 109//9 +f 119//10 120//10 116//10 +f 106//9 109//9 108//9 +f 118//10 119//10 116//10 +f 108//9 107//9 106//9 +f 116//10 117//10 118//10 +f 106//79 107//79 112//79 111//79 +f 107//80 108//80 113//80 112//80 +f 108//81 109//81 114//81 113//81 +f 109//82 110//82 115//82 114//82 +f 110//83 106//83 111//83 115//83 +f 111//69 112//69 117//69 116//69 +f 112//70 113//70 118//70 117//70 +f 113//71 114//71 119//71 118//71 +f 114//72 115//72 120//72 119//72 +f 115//73 111//73 116//73 120//73 +f 121//9 124//9 123//9 +f 131//10 132//10 129//10 +f 123//9 122//9 121//9 +f 129//10 130//10 131//10 +f 121//84 122//84 126//84 125//84 +f 122//85 123//85 127//85 126//85 +f 123//86 124//86 128//86 127//86 +f 124//87 121//87 125//87 128//87 +f 125//88 126//88 130//88 129//88 +f 126//89 127//89 131//89 130//89 +f 127//90 128//90 132//90 131//90 +f 128//91 125//91 129//91 132//91 +f 133//9 136//9 135//9 +f 143//10 144//10 141//10 +f 135//9 134//9 133//9 +f 141//10 142//10 143//10 +f 133//92 134//92 138//92 137//92 +f 134//93 135//93 139//93 138//93 +f 135//94 136//94 140//94 139//94 +f 136//95 133//95 137//95 140//95 +f 137//84 138//84 142//84 141//84 +f 138//85 139//85 143//85 142//85 +f 139//86 140//86 144//86 143//86 +f 140//87 137//87 141//87 144//87 o neck usemtl champagne -f 217//113 218//113 235//113 234//113 -f 218//114 219//114 236//114 235//114 -f 219//115 220//115 237//115 236//115 -f 224//116 225//116 242//116 241//116 -f 225//117 226//117 243//117 242//117 -f 226//118 227//118 244//118 243//118 -f 227//119 228//119 245//119 244//119 -f 228//120 229//120 246//120 245//120 -f 229//121 230//121 247//121 246//121 -f 233//122 217//122 234//122 250//122 -f 234//123 235//123 252//123 251//123 -f 235//124 236//124 253//124 252//124 -f 236//125 237//125 254//125 253//125 -f 240//126 241//126 258//126 257//126 -f 241//127 242//127 259//127 258//127 -f 242//128 243//128 260//128 259//128 -f 243//129 244//129 261//129 260//129 -f 244//130 245//130 262//130 261//130 -f 245//131 246//131 263//131 262//131 -f 246//132 247//132 264//132 263//132 -f 248//133 249//133 266//133 265//133 -f 249//134 250//134 267//134 266//134 -f 250//135 234//135 251//135 267//135 -f 217//9 233//9 232//9 -f 266//10 267//10 251//10 -f 217//9 229//9 228//9 -f 262//10 263//10 251//10 -f 217//9 228//9 227//9 -f 261//10 262//10 251//10 -f 217//9 221//9 220//9 -f 254//10 255//10 251//10 -f 217//9 220//9 219//9 -f 253//10 254//10 251//10 -f 219//9 218//9 217//9 -f 251//10 252//10 253//10 -f 270//9 269//9 268//9 -f 274//10 275//10 276//10 -f 268//136 269//136 272//136 271//136 -f 269//137 270//137 273//137 272//137 -f 270//138 268//138 271//138 273//138 -f 271//139 272//139 275//139 274//139 -f 272//140 273//140 276//140 275//140 -f 273//141 271//141 274//141 276//141 -f 279//9 278//9 277//9 -f 283//10 284//10 285//10 -f 277//142 278//142 281//142 280//142 -f 278//143 279//143 282//143 281//143 -f 279//144 277//144 280//144 282//144 -f 280//136 281//136 284//136 283//136 -f 281//137 282//137 285//137 284//137 -f 282//138 280//138 283//138 285//138 -f 364//9 367//9 366//9 -f 374//10 375//10 372//10 -f 366//9 365//9 364//9 -f 372//10 373//10 374//10 -f 364//145 365//145 369//145 368//145 -f 365//146 366//146 370//146 369//146 -f 366//147 367//147 371//147 370//147 -f 367//148 364//148 368//148 371//148 -f 368//149 369//149 373//149 372//149 -f 369//150 370//150 374//150 373//150 -f 370//151 371//151 375//151 374//151 -f 371//152 368//152 372//152 375//152 -f 376//9 379//9 378//9 -f 386//10 387//10 384//10 -f 378//9 377//9 376//9 -f 384//10 385//10 386//10 -f 376//153 377//153 381//153 380//153 -f 377//154 378//154 382//154 381//154 -f 378//155 379//155 383//155 382//155 -f 379//156 376//156 380//156 383//156 -f 380//145 381//145 385//145 384//145 -f 381//146 382//146 386//146 385//146 -f 382//147 383//147 387//147 386//147 -f 383//148 380//148 384//148 387//148 +f 181//96 182//96 199//96 198//96 +f 182//97 183//97 200//97 199//97 +f 183//98 184//98 201//98 200//98 +f 188//99 189//99 206//99 205//99 +f 189//100 190//100 207//100 206//100 +f 190//101 191//101 208//101 207//101 +f 191//102 192//102 209//102 208//102 +f 192//103 193//103 210//103 209//103 +f 193//104 194//104 211//104 210//104 +f 197//105 181//105 198//105 214//105 +f 198//106 199//106 216//106 215//106 +f 199//107 200//107 217//107 216//107 +f 200//108 201//108 218//108 217//108 +f 204//109 205//109 222//109 221//109 +f 205//110 206//110 223//110 222//110 +f 206//111 207//111 224//111 223//111 +f 207//112 208//112 225//112 224//112 +f 208//113 209//113 226//113 225//113 +f 209//114 210//114 227//114 226//114 +f 210//115 211//115 228//115 227//115 +f 212//116 213//116 230//116 229//116 +f 213//117 214//117 231//117 230//117 +f 214//118 198//118 215//118 231//118 +f 181//9 197//9 196//9 +f 230//10 231//10 215//10 +f 181//9 193//9 192//9 +f 226//10 227//10 215//10 +f 181//9 192//9 191//9 +f 225//10 226//10 215//10 +f 181//9 185//9 184//9 +f 218//10 219//10 215//10 +f 181//9 184//9 183//9 +f 217//10 218//10 215//10 +f 183//9 182//9 181//9 +f 215//10 216//10 217//10 +f 234//9 233//9 232//9 +f 238//10 239//10 240//10 +f 232//119 233//119 236//119 235//119 +f 233//120 234//120 237//120 236//120 +f 234//121 232//121 235//121 237//121 +f 235//122 236//122 239//122 238//122 +f 236//123 237//123 240//123 239//123 +f 237//124 235//124 238//124 240//124 +f 243//9 242//9 241//9 +f 247//10 248//10 249//10 +f 241//125 242//125 245//125 244//125 +f 242//126 243//126 246//126 245//126 +f 243//127 241//127 244//127 246//127 +f 244//119 245//119 248//119 247//119 +f 245//120 246//120 249//120 248//120 +f 246//121 244//121 247//121 249//121 +f 328//9 331//9 330//9 +f 338//10 339//10 336//10 +f 330//9 329//9 328//9 +f 336//10 337//10 338//10 +f 328//128 329//128 333//128 332//128 +f 329//129 330//129 334//129 333//129 +f 330//130 331//130 335//130 334//130 +f 331//131 328//131 332//131 335//131 +f 332//132 333//132 337//132 336//132 +f 333//133 334//133 338//133 337//133 +f 334//134 335//134 339//134 338//134 +f 335//135 332//135 336//135 339//135 +f 340//9 343//9 342//9 +f 350//10 351//10 348//10 +f 342//9 341//9 340//9 +f 348//10 349//10 350//10 +f 340//136 341//136 345//136 344//136 +f 341//137 342//137 346//137 345//137 +f 342//138 343//138 347//138 346//138 +f 343//139 340//139 344//139 347//139 +f 344//128 345//128 349//128 348//128 +f 345//129 346//129 350//129 349//129 +f 346//130 347//130 351//130 350//130 +f 347//131 344//131 348//131 351//131 usemtl dark -f 220//157 221//157 238//157 237//157 -f 221//158 222//158 239//158 238//158 -f 223//159 224//159 241//159 240//159 -f 230//160 231//160 248//160 247//160 -f 237//161 238//161 255//161 254//161 -f 238//162 239//162 256//162 255//162 -f 247//163 248//163 265//163 264//163 -f 217//9 227//9 226//9 -f 260//10 261//10 251//10 -f 217//9 226//9 225//9 -f 259//10 260//10 251//10 -f 217//9 225//9 224//9 -f 258//10 259//10 251//10 -f 217//9 224//9 223//9 -f 257//10 258//10 251//10 -f 217//9 223//9 222//9 -f 256//10 257//10 251//10 -f 217//9 222//9 221//9 -f 255//10 256//10 251//10 -f 334//9 338//9 337//9 -f 347//10 348//10 344//10 -f 334//9 337//9 336//9 -f 346//10 347//10 344//10 -f 336//9 335//9 334//9 -f 344//10 345//10 346//10 -f 334//164 335//164 340//164 339//164 -f 335//165 336//165 341//165 340//165 -f 336//166 337//166 342//166 341//166 -f 337//167 338//167 343//167 342//167 -f 338//168 334//168 339//168 343//168 -f 339//169 340//169 345//169 344//169 -f 340//170 341//170 346//170 345//170 -f 341//171 342//171 347//171 346//171 -f 342//172 343//172 348//172 347//172 -f 343//173 339//173 344//173 348//173 -f 349//9 353//9 352//9 -f 362//10 363//10 359//10 -f 349//9 352//9 351//9 -f 361//10 362//10 359//10 -f 351//9 350//9 349//9 -f 359//10 360//10 361//10 -f 349//174 350//174 355//174 354//174 -f 350//175 351//175 356//175 355//175 -f 351//176 352//176 357//176 356//176 -f 352//177 353//177 358//177 357//177 -f 353//178 349//178 354//178 358//178 -f 354//164 355//164 360//164 359//164 -f 355//165 356//165 361//165 360//165 -f 356//166 357//166 362//166 361//166 -f 357//167 358//167 363//167 362//167 -f 358//168 354//168 359//168 363//168 +f 184//140 185//140 202//140 201//140 +f 185//141 186//141 203//141 202//141 +f 187//142 188//142 205//142 204//142 +f 194//143 195//143 212//143 211//143 +f 201//144 202//144 219//144 218//144 +f 202//145 203//145 220//145 219//145 +f 211//146 212//146 229//146 228//146 +f 181//9 191//9 190//9 +f 224//10 225//10 215//10 +f 181//9 190//9 189//9 +f 223//10 224//10 215//10 +f 181//9 189//9 188//9 +f 222//10 223//10 215//10 +f 181//9 188//9 187//9 +f 221//10 222//10 215//10 +f 181//9 187//9 186//9 +f 220//10 221//10 215//10 +f 181//9 186//9 185//9 +f 219//10 220//10 215//10 +f 298//9 302//9 301//9 +f 311//10 312//10 308//10 +f 298//9 301//9 300//9 +f 310//10 311//10 308//10 +f 300//9 299//9 298//9 +f 308//10 309//10 310//10 +f 298//147 299//147 304//147 303//147 +f 299//148 300//148 305//148 304//148 +f 300//149 301//149 306//149 305//149 +f 301//150 302//150 307//150 306//150 +f 302//151 298//151 303//151 307//151 +f 303//152 304//152 309//152 308//152 +f 304//153 305//153 310//153 309//153 +f 305//154 306//154 311//154 310//154 +f 306//155 307//155 312//155 311//155 +f 307//156 303//156 308//156 312//156 +f 313//9 317//9 316//9 +f 326//10 327//10 323//10 +f 313//9 316//9 315//9 +f 325//10 326//10 323//10 +f 315//9 314//9 313//9 +f 323//10 324//10 325//10 +f 313//157 314//157 319//157 318//157 +f 314//158 315//158 320//158 319//158 +f 315//159 316//159 321//159 320//159 +f 316//160 317//160 322//160 321//160 +f 317//161 313//161 318//161 322//161 +f 318//147 319//147 324//147 323//147 +f 319//148 320//148 325//148 324//148 +f 320//149 321//149 326//149 325//149 +f 321//150 322//150 327//150 326//150 +f 322//151 318//151 323//151 327//151 usemtl glass -f 222//179 223//179 240//179 239//179 -f 231//180 232//180 249//180 248//180 -f 232//181 233//181 250//181 249//181 -f 239//182 240//182 257//182 256//182 -f 217//9 232//9 231//9 -f 265//10 266//10 251//10 -f 217//9 231//9 230//9 -f 264//10 265//10 251//10 -f 217//9 230//9 229//9 -f 263//10 264//10 251//10 -f 286//9 290//9 289//9 -f 299//10 300//10 296//10 -f 286//9 289//9 288//9 -f 298//10 299//10 296//10 -f 288//9 287//9 286//9 -f 296//10 297//10 298//10 -f 286//183 287//183 292//183 291//183 -f 287//184 288//184 293//184 292//184 -f 288//185 289//185 294//185 293//185 -f 289//186 290//186 295//186 294//186 -f 290//187 286//187 291//187 295//187 -f 291//188 292//188 297//188 296//188 -f 292//189 293//189 298//189 297//189 -f 293//190 294//190 299//190 298//190 -f 294//191 295//191 300//191 299//191 -f 295//192 291//192 296//192 300//192 -f 301//9 305//9 304//9 -f 314//10 315//10 311//10 -f 301//9 304//9 303//9 -f 313//10 314//10 311//10 -f 303//9 302//9 301//9 -f 311//10 312//10 313//10 -f 301//193 302//193 307//193 306//193 -f 302//194 303//194 308//194 307//194 -f 303//195 304//195 309//195 308//195 -f 304//196 305//196 310//196 309//196 -f 305//197 301//197 306//197 310//197 -f 306//183 307//183 312//183 311//183 -f 307//184 308//184 313//184 312//184 -f 308//185 309//185 314//185 313//185 -f 309//186 310//186 315//186 314//186 -f 310//187 306//187 311//187 315//187 -f 318//9 317//9 316//9 -f 322//10 323//10 324//10 -f 316//198 317//198 320//198 319//198 -f 317//199 318//199 321//199 320//199 -f 318//200 316//200 319//200 321//200 -f 319//201 320//201 323//201 322//201 -f 320//202 321//202 324//202 323//202 -f 321//203 319//203 322//203 324//203 -f 327//9 326//9 325//9 -f 331//10 332//10 333//10 -f 325//204 326//204 329//204 328//204 -f 326//205 327//205 330//205 329//205 -f 327//206 325//206 328//206 330//206 -f 328//198 329//198 332//198 331//198 -f 329//199 330//199 333//199 332//199 -f 330//200 328//200 331//200 333//200 +f 186//162 187//162 204//162 203//162 +f 195//163 196//163 213//163 212//163 +f 196//164 197//164 214//164 213//164 +f 203//165 204//165 221//165 220//165 +f 181//9 196//9 195//9 +f 229//10 230//10 215//10 +f 181//9 195//9 194//9 +f 228//10 229//10 215//10 +f 181//9 194//9 193//9 +f 227//10 228//10 215//10 +f 250//9 254//9 253//9 +f 263//10 264//10 260//10 +f 250//9 253//9 252//9 +f 262//10 263//10 260//10 +f 252//9 251//9 250//9 +f 260//10 261//10 262//10 +f 250//166 251//166 256//166 255//166 +f 251//167 252//167 257//167 256//167 +f 252//168 253//168 258//168 257//168 +f 253//169 254//169 259//169 258//169 +f 254//170 250//170 255//170 259//170 +f 255//171 256//171 261//171 260//171 +f 256//172 257//172 262//172 261//172 +f 257//173 258//173 263//173 262//173 +f 258//174 259//174 264//174 263//174 +f 259//175 255//175 260//175 264//175 +f 265//9 269//9 268//9 +f 278//10 279//10 275//10 +f 265//9 268//9 267//9 +f 277//10 278//10 275//10 +f 267//9 266//9 265//9 +f 275//10 276//10 277//10 +f 265//176 266//176 271//176 270//176 +f 266//177 267//177 272//177 271//177 +f 267//178 268//178 273//178 272//178 +f 268//179 269//179 274//179 273//179 +f 269//180 265//180 270//180 274//180 +f 270//166 271//166 276//166 275//166 +f 271//167 272//167 277//167 276//167 +f 272//168 273//168 278//168 277//168 +f 273//169 274//169 279//169 278//169 +f 274//170 270//170 275//170 279//170 +f 282//9 281//9 280//9 +f 286//10 287//10 288//10 +f 280//181 281//181 284//181 283//181 +f 281//182 282//182 285//182 284//182 +f 282//183 280//183 283//183 285//183 +f 283//184 284//184 287//184 286//184 +f 284//185 285//185 288//185 287//185 +f 285//186 283//186 286//186 288//186 +f 291//9 290//9 289//9 +f 295//10 296//10 297//10 +f 289//187 290//187 293//187 292//187 +f 290//188 291//188 294//188 293//188 +f 291//189 289//189 292//189 294//189 +f 292//181 293//181 296//181 295//181 +f 293//182 294//182 297//182 296//182 +f 294//183 292//183 295//183 297//183 o body usemtl glass -f 388//207 389//207 415//207 414//207 -f 389//208 390//208 416//208 415//208 -f 390//209 391//209 417//209 416//209 -f 391//210 392//210 418//210 417//210 -f 394//211 395//211 421//211 420//211 -f 395//212 396//212 422//212 421//212 -f 413//213 388//213 414//213 439//213 -f 414//214 415//214 441//214 440//214 -f 415//215 416//215 442//215 441//215 -f 416//216 417//216 443//216 442//216 -f 417//217 418//217 444//217 443//217 -f 420//218 421//218 447//218 446//218 -f 421//219 422//219 448//219 447//219 -f 439//220 414//220 440//220 465//220 -f 388//9 413//9 412//9 -f 464//10 465//10 440//10 -f 388//9 412//9 400//9 -f 452//10 464//10 440//10 -f 388//9 400//9 399//9 -f 451//10 452//10 440//10 -f 388//9 397//9 396//9 -f 448//10 449//10 440//10 -f 388//9 396//9 395//9 -f 447//10 448//10 440//10 -f 395//9 394//9 393//9 -f 445//10 446//10 447//10 -f 395//9 393//9 392//9 -f 444//10 445//10 447//10 -f 395//9 392//9 391//9 -f 443//10 444//10 447//10 -f 395//9 391//9 390//9 -f 442//10 443//10 447//10 -f 395//9 390//9 389//9 -f 441//10 442//10 447//10 -f 395//9 389//9 388//9 -f 440//10 441//10 447//10 -f 466//9 469//9 468//9 -f 476//10 477//10 474//10 -f 468//9 467//9 466//9 -f 474//10 475//10 476//10 -f 466//221 467//221 471//221 470//221 -f 467//222 468//222 472//222 471//222 -f 468//223 469//223 473//223 472//223 -f 469//224 466//224 470//224 473//224 -f 470//225 471//225 475//225 474//225 -f 471//226 472//226 476//226 475//226 -f 472//227 473//227 477//227 476//227 -f 473//228 470//228 474//228 477//228 -f 478//9 481//9 480//9 -f 488//10 489//10 486//10 -f 480//9 479//9 478//9 -f 486//10 487//10 488//10 -f 478//229 479//229 483//229 482//229 -f 479//230 480//230 484//230 483//230 -f 480//231 481//231 485//231 484//231 -f 481//232 478//232 482//232 485//232 -f 482//221 483//221 487//221 486//221 -f 483//222 484//222 488//222 487//222 -f 484//223 485//223 489//223 488//223 -f 485//224 482//224 486//224 489//224 -f 492//9 491//9 490//9 -f 496//10 497//10 498//10 -f 490//233 491//233 494//233 493//233 -f 491//234 492//234 495//234 494//234 -f 492//235 490//235 493//235 495//235 -f 493//236 494//236 497//236 496//236 -f 494//237 495//237 498//237 497//237 -f 495//238 493//238 496//238 498//238 -f 501//9 500//9 499//9 -f 505//10 506//10 507//10 -f 499//239 500//239 503//239 502//239 -f 500//240 501//240 504//240 503//240 -f 501//241 499//241 502//241 504//241 -f 502//233 503//233 506//233 505//233 -f 503//234 504//234 507//234 506//234 -f 504//235 502//235 505//235 507//235 -f 508//9 512//9 511//9 -f 521//10 522//10 518//10 -f 508//9 511//9 510//9 -f 520//10 521//10 518//10 -f 510//9 509//9 508//9 -f 518//10 519//10 520//10 -f 508//242 509//242 514//242 513//242 -f 509//243 510//243 515//243 514//243 -f 510//244 511//244 516//244 515//244 -f 511//245 512//245 517//245 516//245 -f 512//246 508//246 513//246 517//246 -f 513//247 514//247 519//247 518//247 -f 514//248 515//248 520//248 519//248 -f 515//249 516//249 521//249 520//249 -f 516//250 517//250 522//250 521//250 -f 517//251 513//251 518//251 522//251 -f 523//9 527//9 526//9 -f 536//10 537//10 533//10 -f 523//9 526//9 525//9 -f 535//10 536//10 533//10 -f 525//9 524//9 523//9 -f 533//10 534//10 535//10 -f 523//252 524//252 529//252 528//252 -f 524//253 525//253 530//253 529//253 -f 525//254 526//254 531//254 530//254 -f 526//255 527//255 532//255 531//255 -f 527//256 523//256 528//256 532//256 -f 528//242 529//242 534//242 533//242 -f 529//243 530//243 535//243 534//243 -f 530//244 531//244 536//244 535//244 -f 531//245 532//245 537//245 536//245 -f 532//246 528//246 533//246 537//246 +f 352//190 353//190 379//190 378//190 +f 353//191 354//191 380//191 379//191 +f 354//192 355//192 381//192 380//192 +f 355//193 356//193 382//193 381//193 +f 358//194 359//194 385//194 384//194 +f 359//195 360//195 386//195 385//195 +f 377//196 352//196 378//196 403//196 +f 378//197 379//197 405//197 404//197 +f 379//198 380//198 406//198 405//198 +f 380//199 381//199 407//199 406//199 +f 381//200 382//200 408//200 407//200 +f 384//201 385//201 411//201 410//201 +f 385//202 386//202 412//202 411//202 +f 403//203 378//203 404//203 429//203 +f 352//9 377//9 376//9 +f 428//10 429//10 404//10 +f 352//9 376//9 364//9 +f 416//10 428//10 404//10 +f 352//9 364//9 363//9 +f 415//10 416//10 404//10 +f 352//9 361//9 360//9 +f 412//10 413//10 404//10 +f 352//9 360//9 359//9 +f 411//10 412//10 404//10 +f 359//9 358//9 357//9 +f 409//10 410//10 411//10 +f 359//9 357//9 356//9 +f 408//10 409//10 411//10 +f 359//9 356//9 355//9 +f 407//10 408//10 411//10 +f 359//9 355//9 354//9 +f 406//10 407//10 411//10 +f 359//9 354//9 353//9 +f 405//10 406//10 411//10 +f 359//9 353//9 352//9 +f 404//10 405//10 411//10 +f 430//9 433//9 432//9 +f 440//10 441//10 438//10 +f 432//9 431//9 430//9 +f 438//10 439//10 440//10 +f 430//204 431//204 435//204 434//204 +f 431//205 432//205 436//205 435//205 +f 432//206 433//206 437//206 436//206 +f 433//207 430//207 434//207 437//207 +f 434//208 435//208 439//208 438//208 +f 435//209 436//209 440//209 439//209 +f 436//210 437//210 441//210 440//210 +f 437//211 434//211 438//211 441//211 +f 442//9 445//9 444//9 +f 452//10 453//10 450//10 +f 444//9 443//9 442//9 +f 450//10 451//10 452//10 +f 442//212 443//212 447//212 446//212 +f 443//213 444//213 448//213 447//213 +f 444//214 445//214 449//214 448//214 +f 445//215 442//215 446//215 449//215 +f 446//204 447//204 451//204 450//204 +f 447//205 448//205 452//205 451//205 +f 448//206 449//206 453//206 452//206 +f 449//207 446//207 450//207 453//207 +f 456//9 455//9 454//9 +f 460//10 461//10 462//10 +f 454//216 455//216 458//216 457//216 +f 455//217 456//217 459//217 458//217 +f 456//218 454//218 457//218 459//218 +f 457//219 458//219 461//219 460//219 +f 458//220 459//220 462//220 461//220 +f 459//221 457//221 460//221 462//221 +f 465//9 464//9 463//9 +f 469//10 470//10 471//10 +f 463//222 464//222 467//222 466//222 +f 464//223 465//223 468//223 467//223 +f 465//224 463//224 466//224 468//224 +f 466//216 467//216 470//216 469//216 +f 467//217 468//217 471//217 470//217 +f 468//218 466//218 469//218 471//218 +f 472//9 476//9 475//9 +f 485//10 486//10 482//10 +f 472//9 475//9 474//9 +f 484//10 485//10 482//10 +f 474//9 473//9 472//9 +f 482//10 483//10 484//10 +f 472//225 473//225 478//225 477//225 +f 473//226 474//226 479//226 478//226 +f 474//227 475//227 480//227 479//227 +f 475//228 476//228 481//228 480//228 +f 476//229 472//229 477//229 481//229 +f 477//230 478//230 483//230 482//230 +f 478//231 479//231 484//231 483//231 +f 479//232 480//232 485//232 484//232 +f 480//233 481//233 486//233 485//233 +f 481//234 477//234 482//234 486//234 +f 487//9 491//9 490//9 +f 500//10 501//10 497//10 +f 487//9 490//9 489//9 +f 499//10 500//10 497//10 +f 489//9 488//9 487//9 +f 497//10 498//10 499//10 +f 487//235 488//235 493//235 492//235 +f 488//236 489//236 494//236 493//236 +f 489//237 490//237 495//237 494//237 +f 490//238 491//238 496//238 495//238 +f 491//239 487//239 492//239 496//239 +f 492//225 493//225 498//225 497//225 +f 493//226 494//226 499//226 498//226 +f 494//227 495//227 500//227 499//227 +f 495//228 496//228 501//228 500//228 +f 496//229 492//229 497//229 501//229 usemtl dark -f 392//257 393//257 419//257 418//257 -f 393//258 394//258 420//258 419//258 -f 398//259 399//259 425//259 424//259 -f 399//260 400//260 426//260 425//260 -f 400//261 401//261 427//261 426//261 -f 409//207 410//207 436//207 435//207 -f 410//262 411//262 437//262 436//262 -f 411//210 412//210 438//210 437//210 -f 412//263 413//263 439//263 438//263 -f 418//264 419//264 445//264 444//264 -f 419//265 420//265 446//265 445//265 -f 424//266 425//266 451//266 450//266 -f 425//267 426//267 452//267 451//267 -f 426//268 427//268 453//268 452//268 -f 435//214 436//214 462//214 461//214 -f 436//269 437//269 463//269 462//269 -f 437//217 438//217 464//217 463//217 -f 438//270 439//270 465//270 464//270 -f 411//9 410//9 409//9 -f 461//10 462//10 463//10 -f 600//9 599//9 598//9 -f 604//10 605//10 606//10 -f 598//271 599//271 602//271 601//271 -f 599//272 600//272 603//272 602//272 -f 600//273 598//273 601//273 603//273 -f 601//274 602//274 605//274 604//274 -f 602//275 603//275 606//275 605//275 -f 603//276 601//276 604//276 606//276 -f 609//9 608//9 607//9 -f 613//10 614//10 615//10 -f 607//277 608//277 611//277 610//277 -f 608//278 609//278 612//278 611//278 -f 609//279 607//279 610//279 612//279 -f 610//271 611//271 614//271 613//271 -f 611//272 612//272 615//272 614//272 -f 612//273 610//273 613//273 615//273 -usemtl champagne -f 396//280 397//280 423//280 422//280 -f 397//281 398//281 424//281 423//281 -f 401//282 402//282 428//282 427//282 -f 402//208 403//208 429//208 428//208 -f 403//207 404//207 430//207 429//207 -f 404//283 405//283 431//283 430//283 -f 405//284 406//284 432//284 431//284 -f 406//208 407//208 433//208 432//208 -f 407//285 408//285 434//285 433//285 -f 408//208 409//208 435//208 434//208 -f 422//286 423//286 449//286 448//286 -f 423//287 424//287 450//287 449//287 -f 427//288 428//288 454//288 453//288 -f 428//215 429//215 455//215 454//215 -f 429//214 430//214 456//214 455//214 -f 430//289 431//289 457//289 456//289 -f 431//290 432//290 458//290 457//290 -f 432//215 433//215 459//215 458//215 -f 433//291 434//291 460//291 459//291 -f 434//215 435//215 461//215 460//215 -f 411//9 409//9 408//9 -f 460//10 461//10 463//10 -f 411//9 408//9 407//9 -f 459//10 460//10 463//10 -f 411//9 407//9 406//9 -f 458//10 459//10 463//10 -f 412//9 411//9 406//9 -f 458//10 463//10 464//10 -f 405//9 404//9 403//9 -f 455//10 456//10 457//10 -f 405//9 403//9 402//9 -f 454//10 455//10 457//10 -f 405//9 402//9 401//9 -f 453//10 454//10 457//10 -f 406//9 405//9 401//9 -f 453//10 457//10 458//10 -f 412//9 406//9 401//9 -f 453//10 458//10 464//10 -f 412//9 401//9 400//9 -f 452//10 453//10 464//10 -f 388//9 399//9 398//9 -f 450//10 451//10 440//10 -f 388//9 398//9 397//9 -f 449//10 450//10 440//10 -f 538//9 541//9 540//9 -f 548//10 549//10 546//10 -f 540//9 539//9 538//9 -f 546//10 547//10 548//10 -f 538//292 539//292 543//292 542//292 -f 539//293 540//293 544//293 543//293 -f 540//294 541//294 545//294 544//294 -f 541//295 538//295 542//295 545//295 -f 542//296 543//296 547//296 546//296 -f 543//297 544//297 548//297 547//297 -f 544//298 545//298 549//298 548//298 -f 545//299 542//299 546//299 549//299 -f 550//9 553//9 552//9 -f 560//10 561//10 558//10 -f 552//9 551//9 550//9 -f 558//10 559//10 560//10 -f 550//300 551//300 555//300 554//300 -f 551//301 552//301 556//301 555//301 -f 552//302 553//302 557//302 556//302 -f 553//303 550//303 554//303 557//303 -f 554//292 555//292 559//292 558//292 -f 555//293 556//293 560//293 559//293 -f 556//294 557//294 561//294 560//294 -f 557//295 554//295 558//295 561//295 +f 356//240 357//240 383//240 382//240 +f 357//241 358//241 384//241 383//241 +f 362//242 363//242 389//242 388//242 +f 363//243 364//243 390//243 389//243 +f 364//244 365//244 391//244 390//244 +f 373//190 374//190 400//190 399//190 +f 374//245 375//245 401//245 400//245 +f 375//193 376//193 402//193 401//193 +f 376//246 377//246 403//246 402//246 +f 382//247 383//247 409//247 408//247 +f 383//248 384//248 410//248 409//248 +f 388//249 389//249 415//249 414//249 +f 389//250 390//250 416//250 415//250 +f 390//251 391//251 417//251 416//251 +f 399//197 400//197 426//197 425//197 +f 400//252 401//252 427//252 426//252 +f 401//200 402//200 428//200 427//200 +f 402//253 403//253 429//253 428//253 +f 375//9 374//9 373//9 +f 425//10 426//10 427//10 f 564//9 563//9 562//9 f 568//10 569//10 570//10 -f 562//304 563//304 566//304 565//304 -f 563//305 564//305 567//305 566//305 -f 564//306 562//306 565//306 567//306 -f 565//307 566//307 569//307 568//307 -f 566//308 567//308 570//308 569//308 -f 567//309 565//309 568//309 570//309 +f 562//254 563//254 566//254 565//254 +f 563//255 564//255 567//255 566//255 +f 564//256 562//256 565//256 567//256 +f 565//257 566//257 569//257 568//257 +f 566//258 567//258 570//258 569//258 +f 567//259 565//259 568//259 570//259 f 573//9 572//9 571//9 f 577//10 578//10 579//10 -f 571//310 572//310 575//310 574//310 -f 572//311 573//311 576//311 575//311 -f 573//312 571//312 574//312 576//312 -f 574//304 575//304 578//304 577//304 -f 575//305 576//305 579//305 578//305 -f 576//306 574//306 577//306 579//306 -f 582//9 581//9 580//9 -f 586//10 587//10 588//10 -f 580//313 581//313 584//313 583//313 -f 581//314 582//314 585//314 584//314 -f 582//315 580//315 583//315 585//315 -f 583//316 584//316 587//316 586//316 -f 584//317 585//317 588//317 587//317 -f 585//318 583//318 586//318 588//318 -f 591//9 590//9 589//9 -f 595//10 596//10 597//10 -f 589//319 590//319 593//319 592//319 -f 590//320 591//320 594//320 593//320 -f 591//321 589//321 592//321 594//321 -f 592//313 593//313 596//313 595//313 -f 593//314 594//314 597//314 596//314 -f 594//315 592//315 595//315 597//315 +f 571//260 572//260 575//260 574//260 +f 572//261 573//261 576//261 575//261 +f 573//262 571//262 574//262 576//262 +f 574//254 575//254 578//254 577//254 +f 575//255 576//255 579//255 578//255 +f 576//256 574//256 577//256 579//256 +usemtl champagne +f 360//263 361//263 387//263 386//263 +f 361//264 362//264 388//264 387//264 +f 365//265 366//265 392//265 391//265 +f 366//191 367//191 393//191 392//191 +f 367//190 368//190 394//190 393//190 +f 368//266 369//266 395//266 394//266 +f 369//267 370//267 396//267 395//267 +f 370//191 371//191 397//191 396//191 +f 371//268 372//268 398//268 397//268 +f 372//191 373//191 399//191 398//191 +f 386//269 387//269 413//269 412//269 +f 387//270 388//270 414//270 413//270 +f 391//271 392//271 418//271 417//271 +f 392//198 393//198 419//198 418//198 +f 393//197 394//197 420//197 419//197 +f 394//272 395//272 421//272 420//272 +f 395//273 396//273 422//273 421//273 +f 396//198 397//198 423//198 422//198 +f 397//274 398//274 424//274 423//274 +f 398//198 399//198 425//198 424//198 +f 375//9 373//9 372//9 +f 424//10 425//10 427//10 +f 375//9 372//9 371//9 +f 423//10 424//10 427//10 +f 375//9 371//9 370//9 +f 422//10 423//10 427//10 +f 376//9 375//9 370//9 +f 422//10 427//10 428//10 +f 369//9 368//9 367//9 +f 419//10 420//10 421//10 +f 369//9 367//9 366//9 +f 418//10 419//10 421//10 +f 369//9 366//9 365//9 +f 417//10 418//10 421//10 +f 370//9 369//9 365//9 +f 417//10 421//10 422//10 +f 376//9 370//9 365//9 +f 417//10 422//10 428//10 +f 376//9 365//9 364//9 +f 416//10 417//10 428//10 +f 352//9 363//9 362//9 +f 414//10 415//10 404//10 +f 352//9 362//9 361//9 +f 413//10 414//10 404//10 +f 502//9 505//9 504//9 +f 512//10 513//10 510//10 +f 504//9 503//9 502//9 +f 510//10 511//10 512//10 +f 502//275 503//275 507//275 506//275 +f 503//276 504//276 508//276 507//276 +f 504//277 505//277 509//277 508//277 +f 505//278 502//278 506//278 509//278 +f 506//279 507//279 511//279 510//279 +f 507//280 508//280 512//280 511//280 +f 508//281 509//281 513//281 512//281 +f 509//282 506//282 510//282 513//282 +f 514//9 517//9 516//9 +f 524//10 525//10 522//10 +f 516//9 515//9 514//9 +f 522//10 523//10 524//10 +f 514//283 515//283 519//283 518//283 +f 515//284 516//284 520//284 519//284 +f 516//285 517//285 521//285 520//285 +f 517//286 514//286 518//286 521//286 +f 518//275 519//275 523//275 522//275 +f 519//276 520//276 524//276 523//276 +f 520//277 521//277 525//277 524//277 +f 521//278 518//278 522//278 525//278 +f 528//9 527//9 526//9 +f 532//10 533//10 534//10 +f 526//287 527//287 530//287 529//287 +f 527//288 528//288 531//288 530//288 +f 528//289 526//289 529//289 531//289 +f 529//290 530//290 533//290 532//290 +f 530//291 531//291 534//291 533//291 +f 531//292 529//292 532//292 534//292 +f 537//9 536//9 535//9 +f 541//10 542//10 543//10 +f 535//293 536//293 539//293 538//293 +f 536//294 537//294 540//294 539//294 +f 537//295 535//295 538//295 540//295 +f 538//287 539//287 542//287 541//287 +f 539//288 540//288 543//288 542//288 +f 540//289 538//289 541//289 543//289 +f 546//9 545//9 544//9 +f 550//10 551//10 552//10 +f 544//296 545//296 548//296 547//296 +f 545//297 546//297 549//297 548//297 +f 546//298 544//298 547//298 549//298 +f 547//299 548//299 551//299 550//299 +f 548//300 549//300 552//300 551//300 +f 549//301 547//301 550//301 552//301 +f 555//9 554//9 553//9 +f 559//10 560//10 561//10 +f 553//302 554//302 557//302 556//302 +f 554//303 555//303 558//303 557//303 +f 555//304 553//304 556//304 558//304 +f 556//296 557//296 560//296 559//296 +f 557//297 558//297 561//297 560//297 +f 558//298 556//298 559//298 561//298 o tail usemtl champagne -f 616//322 617//322 638//322 637//322 -f 617//323 618//323 639//323 638//323 -f 618//324 619//324 640//324 639//324 -f 619//325 620//325 641//325 640//325 -f 620//326 621//326 642//326 641//326 -f 621//327 622//327 643//327 642//327 -f 623//328 624//328 645//328 644//328 -f 624//329 625//329 646//329 645//329 -f 625//330 626//330 647//330 646//330 -f 626//331 627//331 648//331 647//331 -f 632//332 633//332 654//332 653//332 -f 637//333 638//333 659//333 658//333 -f 638//334 639//334 660//334 659//334 -f 639//335 640//335 661//335 660//335 -f 640//336 641//336 662//336 661//336 -f 641//337 642//337 663//337 662//337 -f 642//338 643//338 664//338 663//338 -f 644//339 645//339 666//339 665//339 -f 645//340 646//340 667//340 666//340 -f 646//341 647//341 668//341 667//341 -f 647//342 648//342 669//342 668//342 -f 653//343 654//343 675//343 674//343 -f 616//9 633//9 632//9 -f 674//10 675//10 658//10 -f 627//9 626//9 625//9 -f 667//10 668//10 669//10 -f 630//9 627//9 625//9 -f 667//10 669//10 672//10 -f 630//9 625//9 624//9 -f 666//10 667//10 672//10 -f 630//9 624//9 623//9 -f 665//10 666//10 672//10 -f 616//9 632//9 622//9 -f 664//10 674//10 658//10 -f 622//9 621//9 620//9 -f 662//10 663//10 664//10 -f 616//9 622//9 620//9 -f 662//10 664//10 658//10 -f 616//9 620//9 619//9 -f 661//10 662//10 658//10 -f 616//9 619//9 618//9 -f 660//10 661//10 658//10 -f 618//9 617//9 616//9 +f 580//305 581//305 602//305 601//305 +f 581//306 582//306 603//306 602//306 +f 582//307 583//307 604//307 603//307 +f 583//308 584//308 605//308 604//308 +f 584//309 585//309 606//309 605//309 +f 585//310 586//310 607//310 606//310 +f 587//311 588//311 609//311 608//311 +f 588//312 589//312 610//312 609//312 +f 589//313 590//313 611//313 610//313 +f 590//314 591//314 612//314 611//314 +f 596//315 597//315 618//315 617//315 +f 601//316 602//316 623//316 622//316 +f 602//317 603//317 624//317 623//317 +f 603//318 604//318 625//318 624//318 +f 604//319 605//319 626//319 625//319 +f 605//320 606//320 627//320 626//320 +f 606//321 607//321 628//321 627//321 +f 608//322 609//322 630//322 629//322 +f 609//323 610//323 631//323 630//323 +f 610//324 611//324 632//324 631//324 +f 611//325 612//325 633//325 632//325 +f 617//326 618//326 639//326 638//326 +f 580//9 597//9 596//9 +f 638//10 639//10 622//10 +f 591//9 590//9 589//9 +f 631//10 632//10 633//10 +f 594//9 591//9 589//9 +f 631//10 633//10 636//10 +f 594//9 589//9 588//9 +f 630//10 631//10 636//10 +f 594//9 588//9 587//9 +f 629//10 630//10 636//10 +f 580//9 596//9 586//9 +f 628//10 638//10 622//10 +f 586//9 585//9 584//9 +f 626//10 627//10 628//10 +f 580//9 586//9 584//9 +f 626//10 628//10 622//10 +f 580//9 584//9 583//9 +f 625//10 626//10 622//10 +f 580//9 583//9 582//9 +f 624//10 625//10 622//10 +f 582//9 581//9 580//9 +f 622//10 623//10 624//10 +f 645//9 644//9 643//9 +f 649//10 650//10 651//10 +f 643//327 644//327 647//327 646//327 +f 644//328 645//328 648//328 647//328 +f 645//329 643//329 646//329 648//329 +f 646//330 647//330 650//330 649//330 +f 647//331 648//331 651//331 650//331 +f 648//332 646//332 649//332 651//332 +f 654//9 653//9 652//9 f 658//10 659//10 660//10 -f 681//9 680//9 679//9 -f 685//10 686//10 687//10 -f 679//344 680//344 683//344 682//344 -f 680//345 681//345 684//345 683//345 -f 681//346 679//346 682//346 684//346 -f 682//347 683//347 686//347 685//347 -f 683//348 684//348 687//348 686//348 -f 684//349 682//349 685//349 687//349 -f 690//9 689//9 688//9 -f 694//10 695//10 696//10 -f 688//350 689//350 692//350 691//350 -f 689//351 690//351 693//351 692//351 -f 690//352 688//352 691//352 693//352 -f 691//344 692//344 695//344 694//344 -f 692//345 693//345 696//345 695//345 -f 693//346 691//346 694//346 696//346 -f 735//9 734//9 733//9 -f 739//10 740//10 741//10 -f 733//353 734//353 737//353 736//353 -f 734//354 735//354 738//354 737//354 -f 735//355 733//355 736//355 738//355 -f 736//356 737//356 740//356 739//356 -f 737//357 738//357 741//357 740//357 -f 738//358 736//358 739//358 741//358 -f 744//9 743//9 742//9 -f 748//10 749//10 750//10 -f 742//359 743//359 746//359 745//359 -f 743//360 744//360 747//360 746//360 -f 744//361 742//361 745//361 747//361 -f 745//353 746//353 749//353 748//353 -f 746//354 747//354 750//354 749//354 -f 747//355 745//355 748//355 750//355 -f 753//9 752//9 751//9 -f 757//10 758//10 759//10 -f 751//362 752//362 755//362 754//362 -f 752//363 753//363 756//363 755//363 -f 753//364 751//364 754//364 756//364 -f 754//365 755//365 758//365 757//365 -f 755//366 756//366 759//366 758//366 -f 756//367 754//367 757//367 759//367 -f 762//9 761//9 760//9 -f 766//10 767//10 768//10 -f 760//368 761//368 764//368 763//368 -f 761//369 762//369 765//369 764//369 -f 762//370 760//370 763//370 765//370 -f 763//362 764//362 767//362 766//362 -f 764//363 765//363 768//363 767//363 -f 765//364 763//364 766//364 768//364 -usemtl dark -f 622//371 623//371 644//371 643//371 -f 629//372 630//372 651//372 650//372 -f 630//373 631//373 652//373 651//373 -f 631//374 632//374 653//374 652//374 -f 643//375 644//375 665//375 664//375 -f 650//376 651//376 672//376 671//376 -f 652//377 653//377 674//377 673//377 -f 630//9 623//9 622//9 -f 664//10 665//10 672//10 -f 631//9 630//9 622//9 -f 664//10 672//10 673//10 -f 632//9 631//9 622//9 -f 664//10 673//10 674//10 -f 717//9 716//9 715//9 -f 721//10 722//10 723//10 -f 715//378 716//378 719//378 718//378 -f 716//379 717//379 720//379 719//379 -f 717//380 715//380 718//380 720//380 -f 718//381 719//381 722//381 721//381 -f 719//382 720//382 723//382 722//382 -f 720//383 718//383 721//383 723//383 -f 726//9 725//9 724//9 -f 730//10 731//10 732//10 -f 724//384 725//384 728//384 727//384 -f 725//385 726//385 729//385 728//385 -f 726//386 724//386 727//386 729//386 -f 727//378 728//378 731//378 730//378 -f 728//379 729//379 732//379 731//379 -f 729//380 727//380 730//380 732//380 -usemtl glass -f 627//387 628//387 649//387 648//387 -f 628//388 629//388 650//388 649//388 -f 633//389 634//389 655//389 654//389 -f 634//390 635//390 656//390 655//390 -f 635//391 636//391 657//391 656//391 -f 636//392 616//392 637//392 657//392 -f 648//393 649//393 670//393 669//393 -f 649//394 650//394 671//394 670//394 -f 651//395 652//395 673//395 672//395 -f 654//396 655//396 676//396 675//396 -f 655//397 656//397 677//397 676//397 -f 656//398 657//398 678//398 677//398 -f 657//399 637//399 658//399 678//399 -f 616//9 636//9 635//9 -f 677//10 678//10 658//10 -f 616//9 635//9 634//9 -f 676//10 677//10 658//10 -f 616//9 634//9 633//9 -f 675//10 676//10 658//10 -f 630//9 629//9 628//9 -f 670//10 671//10 672//10 -f 630//9 628//9 627//9 -f 669//10 670//10 672//10 +f 652//333 653//333 656//333 655//333 +f 653//334 654//334 657//334 656//334 +f 654//335 652//335 655//335 657//335 +f 655//327 656//327 659//327 658//327 +f 656//328 657//328 660//328 659//328 +f 657//329 655//329 658//329 660//329 f 699//9 698//9 697//9 f 703//10 704//10 705//10 -f 697//400 698//400 701//400 700//400 -f 698//401 699//401 702//401 701//401 -f 699//402 697//402 700//402 702//402 -f 700//403 701//403 704//403 703//403 -f 701//404 702//404 705//404 704//404 -f 702//405 700//405 703//405 705//405 +f 697//336 698//336 701//336 700//336 +f 698//337 699//337 702//337 701//337 +f 699//338 697//338 700//338 702//338 +f 700//339 701//339 704//339 703//339 +f 701//340 702//340 705//340 704//340 +f 702//341 700//341 703//341 705//341 f 708//9 707//9 706//9 f 712//10 713//10 714//10 -f 706//406 707//406 710//406 709//406 -f 707//407 708//407 711//407 710//407 -f 708//408 706//408 709//408 711//408 -f 709//400 710//400 713//400 712//400 -f 710//401 711//401 714//401 713//401 -f 711//402 709//402 712//402 714//402 -f 769//9 772//9 771//9 -f 779//10 780//10 777//10 -f 771//9 770//9 769//9 -f 777//10 778//10 779//10 -f 769//409 770//409 774//409 773//409 -f 770//410 771//410 775//410 774//410 -f 771//411 772//411 776//411 775//411 -f 772//412 769//412 773//412 776//412 -f 773//413 774//413 778//413 777//413 -f 774//414 775//414 779//414 778//414 -f 775//415 776//415 780//415 779//415 -f 776//416 773//416 777//416 780//416 -f 781//9 784//9 783//9 -f 791//10 792//10 789//10 -f 783//9 782//9 781//9 -f 789//10 790//10 791//10 -f 781//417 782//417 786//417 785//417 -f 782//418 783//418 787//418 786//418 -f 783//419 784//419 788//419 787//419 -f 784//420 781//420 785//420 788//420 -f 785//409 786//409 790//409 789//409 -f 786//410 787//410 791//410 790//410 -f 787//411 788//411 792//411 791//411 -f 788//412 785//412 789//412 792//412 +f 706//342 707//342 710//342 709//342 +f 707//343 708//343 711//343 710//343 +f 708//344 706//344 709//344 711//344 +f 709//336 710//336 713//336 712//336 +f 710//337 711//337 714//337 713//337 +f 711//338 709//338 712//338 714//338 +f 717//9 716//9 715//9 +f 721//10 722//10 723//10 +f 715//345 716//345 719//345 718//345 +f 716//346 717//346 720//346 719//346 +f 717//347 715//347 718//347 720//347 +f 718//348 719//348 722//348 721//348 +f 719//349 720//349 723//349 722//349 +f 720//350 718//350 721//350 723//350 +f 726//9 725//9 724//9 +f 730//10 731//10 732//10 +f 724//351 725//351 728//351 727//351 +f 725//352 726//352 729//352 728//352 +f 726//353 724//353 727//353 729//353 +f 727//345 728//345 731//345 730//345 +f 728//346 729//346 732//346 731//346 +f 729//347 727//347 730//347 732//347 +usemtl dark +f 586//354 587//354 608//354 607//354 +f 593//355 594//355 615//355 614//355 +f 594//356 595//356 616//356 615//356 +f 595//357 596//357 617//357 616//357 +f 607//358 608//358 629//358 628//358 +f 614//359 615//359 636//359 635//359 +f 616//360 617//360 638//360 637//360 +f 594//9 587//9 586//9 +f 628//10 629//10 636//10 +f 595//9 594//9 586//9 +f 628//10 636//10 637//10 +f 596//9 595//9 586//9 +f 628//10 637//10 638//10 +f 681//9 680//9 679//9 +f 685//10 686//10 687//10 +f 679//361 680//361 683//361 682//361 +f 680//362 681//362 684//362 683//362 +f 681//363 679//363 682//363 684//363 +f 682//364 683//364 686//364 685//364 +f 683//365 684//365 687//365 686//365 +f 684//366 682//366 685//366 687//366 +f 690//9 689//9 688//9 +f 694//10 695//10 696//10 +f 688//367 689//367 692//367 691//367 +f 689//368 690//368 693//368 692//368 +f 690//369 688//369 691//369 693//369 +f 691//361 692//361 695//361 694//361 +f 692//362 693//362 696//362 695//362 +f 693//363 691//363 694//363 696//363 +usemtl glass +f 591//370 592//370 613//370 612//370 +f 592//371 593//371 614//371 613//371 +f 597//372 598//372 619//372 618//372 +f 598//373 599//373 620//373 619//373 +f 599//374 600//374 621//374 620//374 +f 600//375 580//375 601//375 621//375 +f 612//376 613//376 634//376 633//376 +f 613//377 614//377 635//377 634//377 +f 615//378 616//378 637//378 636//378 +f 618//379 619//379 640//379 639//379 +f 619//380 620//380 641//380 640//380 +f 620//381 621//381 642//381 641//381 +f 621//382 601//382 622//382 642//382 +f 580//9 600//9 599//9 +f 641//10 642//10 622//10 +f 580//9 599//9 598//9 +f 640//10 641//10 622//10 +f 580//9 598//9 597//9 +f 639//10 640//10 622//10 +f 594//9 593//9 592//9 +f 634//10 635//10 636//10 +f 594//9 592//9 591//9 +f 633//10 634//10 636//10 +f 663//9 662//9 661//9 +f 667//10 668//10 669//10 +f 661//383 662//383 665//383 664//383 +f 662//384 663//384 666//384 665//384 +f 663//385 661//385 664//385 666//385 +f 664//386 665//386 668//386 667//386 +f 665//387 666//387 669//387 668//387 +f 666//388 664//388 667//388 669//388 +f 672//9 671//9 670//9 +f 676//10 677//10 678//10 +f 670//389 671//389 674//389 673//389 +f 671//390 672//390 675//390 674//390 +f 672//391 670//391 673//391 675//391 +f 673//383 674//383 677//383 676//383 +f 674//384 675//384 678//384 677//384 +f 675//385 673//385 676//385 678//385 +f 733//9 736//9 735//9 +f 743//10 744//10 741//10 +f 735//9 734//9 733//9 +f 741//10 742//10 743//10 +f 733//392 734//392 738//392 737//392 +f 734//393 735//393 739//393 738//393 +f 735//394 736//394 740//394 739//394 +f 736//395 733//395 737//395 740//395 +f 737//396 738//396 742//396 741//396 +f 738//397 739//397 743//397 742//397 +f 739//398 740//398 744//398 743//398 +f 740//399 737//399 741//399 744//399 +f 745//9 748//9 747//9 +f 755//10 756//10 753//10 +f 747//9 746//9 745//9 +f 753//10 754//10 755//10 +f 745//400 746//400 750//400 749//400 +f 746//401 747//401 751//401 750//401 +f 747//402 748//402 752//402 751//402 +f 748//403 745//403 749//403 752//403 +f 749//392 750//392 754//392 753//392 +f 750//393 751//393 755//393 754//393 +f 751//394 752//394 756//394 755//394 +f 752//395 749//395 753//395 756//395 +o jaw +usemtl dark +f 757//404 758//404 765//404 764//404 +f 764//405 765//405 772//405 771//405 +f 759//9 758//9 757//9 +f 771//10 772//10 773//10 +usemtl champagne +f 758//406 759//406 766//406 765//406 +f 759//407 760//407 767//407 766//407 +f 760//408 761//408 768//408 767//408 +f 761//409 762//409 769//409 768//409 +f 762//410 763//410 770//410 769//410 +f 765//411 766//411 773//411 772//411 +f 766//412 767//412 774//412 773//412 +f 767//413 768//413 775//413 774//413 +f 768//414 769//414 776//414 775//414 +f 769//415 770//415 777//415 776//415 +f 757//9 763//9 762//9 +f 776//10 777//10 771//10 +f 757//9 762//9 761//9 +f 775//10 776//10 771//10 +f 757//9 761//9 760//9 +f 774//10 775//10 771//10 +f 757//9 760//9 759//9 +f 773//10 774//10 771//10 +f 780//9 779//9 778//9 +f 784//10 785//10 786//10 +f 778//416 779//416 782//416 781//416 +f 779//417 780//417 783//417 782//417 +f 780//418 778//418 781//418 783//418 +f 781//419 782//419 785//419 784//419 +f 782//420 783//420 786//420 785//420 +f 783//421 781//421 784//421 786//421 +f 789//9 788//9 787//9 +f 793//10 794//10 795//10 +f 787//422 788//422 791//422 790//422 +f 788//423 789//423 792//423 791//423 +f 789//424 787//424 790//424 792//424 +f 790//416 791//416 794//416 793//416 +f 791//417 792//417 795//417 794//417 +f 792//418 790//418 793//418 795//418 +usemtl glass +f 763//425 757//425 764//425 770//425 +f 770//426 764//426 771//426 777//426 o hindLegL usemtl dark -f 793//421 794//421 807//421 806//421 -f 794//323 795//323 808//323 807//323 -f 798//323 799//323 812//323 811//323 -f 799//422 800//422 813//422 812//422 -f 800//423 801//423 814//423 813//423 -f 806//424 807//424 820//424 819//424 -f 807//334 808//334 821//334 820//334 -f 812//425 813//425 826//425 825//425 -f 813//426 814//426 827//426 826//426 -f 800//9 799//9 798//9 -f 824//10 825//10 826//10 -f 800//9 798//9 797//9 -f 823//10 824//10 826//10 -f 793//9 800//9 797//9 -f 823//10 826//10 819//10 -f 793//9 797//9 796//9 -f 822//10 823//10 819//10 -f 793//9 796//9 795//9 -f 821//10 822//10 819//10 -f 795//9 794//9 793//9 -f 819//10 820//10 821//10 -f 834//9 833//9 832//9 -f 838//10 839//10 840//10 -f 832//427 833//427 836//427 835//427 -f 833//428 834//428 837//428 836//428 -f 834//429 832//429 835//429 837//429 -f 835//430 836//430 839//430 838//430 -f 836//431 837//431 840//431 839//431 -f 837//432 835//432 838//432 840//432 -f 843//9 842//9 841//9 -f 847//10 848//10 849//10 -f 841//433 842//433 845//433 844//433 -f 842//434 843//434 846//434 845//434 -f 843//435 841//435 844//435 846//435 -f 844//427 845//427 848//427 847//427 -f 845//428 846//428 849//428 848//428 -f 846//429 844//429 847//429 849//429 -f 852//9 851//9 850//9 -f 856//10 857//10 858//10 -f 850//436 851//436 854//436 853//436 -f 851//29 852//29 855//29 854//29 -f 852//437 850//437 853//437 855//437 -f 853//438 854//438 857//438 856//438 -f 854//439 855//439 858//439 857//439 -f 855//440 853//440 856//440 858//440 -f 861//9 860//9 859//9 -f 865//10 866//10 867//10 -f 859//441 860//441 863//441 862//441 -f 860//442 861//442 864//442 863//442 -f 861//443 859//443 862//443 864//443 -f 862//436 863//436 866//436 865//436 -f 863//29 864//29 867//29 866//29 -f 864//437 862//437 865//437 867//437 +f 796//427 797//427 810//427 809//427 +f 797//306 798//306 811//306 810//306 +f 801//306 802//306 815//306 814//306 +f 802//428 803//428 816//428 815//428 +f 803//429 804//429 817//429 816//429 +f 809//430 810//430 823//430 822//430 +f 810//317 811//317 824//317 823//317 +f 815//431 816//431 829//431 828//431 +f 816//432 817//432 830//432 829//432 +f 803//9 802//9 801//9 +f 827//10 828//10 829//10 +f 803//9 801//9 800//9 +f 826//10 827//10 829//10 +f 796//9 803//9 800//9 +f 826//10 829//10 822//10 +f 796//9 800//9 799//9 +f 825//10 826//10 822//10 +f 796//9 799//9 798//9 +f 824//10 825//10 822//10 +f 798//9 797//9 796//9 +f 822//10 823//10 824//10 +f 837//9 836//9 835//9 +f 841//10 842//10 843//10 +f 835//433 836//433 839//433 838//433 +f 836//434 837//434 840//434 839//434 +f 837//435 835//435 838//435 840//435 +f 838//436 839//436 842//436 841//436 +f 839//437 840//437 843//437 842//437 +f 840//438 838//438 841//438 843//438 +f 846//9 845//9 844//9 +f 850//10 851//10 852//10 +f 844//439 845//439 848//439 847//439 +f 845//440 846//440 849//440 848//440 +f 846//441 844//441 847//441 849//441 +f 847//433 848//433 851//433 850//433 +f 848//434 849//434 852//434 851//434 +f 849//435 847//435 850//435 852//435 +f 855//9 854//9 853//9 +f 859//10 860//10 861//10 +f 853//442 854//442 857//442 856//442 +f 854//29 855//29 858//29 857//29 +f 855//443 853//443 856//443 858//443 +f 856//444 857//444 860//444 859//444 +f 857//445 858//445 861//445 860//445 +f 858//446 856//446 859//446 861//446 +f 864//9 863//9 862//9 +f 868//10 869//10 870//10 +f 862//447 863//447 866//447 865//447 +f 863//448 864//448 867//448 866//448 +f 864//449 862//449 865//449 867//449 +f 865//442 866//442 869//442 868//442 +f 866//29 867//29 870//29 869//29 +f 867//443 865//443 868//443 870//443 usemtl champagne -f 795//444 796//444 809//444 808//444 -f 796//323 797//323 810//323 809//323 -f 797//445 798//445 811//445 810//445 -f 801//446 802//446 815//446 814//446 -f 802//423 803//423 816//423 815//423 -f 803//447 804//447 817//447 816//447 -f 804//448 805//448 818//448 817//448 -f 805//323 793//323 806//323 818//323 -f 808//449 809//449 822//449 821//449 -f 809//334 810//334 823//334 822//334 -f 810//450 811//450 824//450 823//450 -f 811//334 812//334 825//334 824//334 -f 814//451 815//451 828//451 827//451 -f 815//426 816//426 829//426 828//426 -f 816//452 817//452 830//452 829//452 -f 817//453 818//453 831//453 830//453 -f 818//334 806//334 819//334 831//334 -f 793//9 805//9 804//9 -f 830//10 831//10 819//10 -f 793//9 804//9 803//9 -f 829//10 830//10 819//10 -f 793//9 803//9 802//9 -f 828//10 829//10 819//10 -f 793//9 802//9 801//9 -f 827//10 828//10 819//10 -f 793//9 801//9 800//9 -f 826//10 827//10 819//10 -f 870//9 869//9 868//9 -f 874//10 875//10 876//10 -f 868//454 869//454 872//454 871//454 -f 869//455 870//455 873//455 872//455 -f 870//456 868//456 871//456 873//456 -f 871//457 872//457 875//457 874//457 -f 872//458 873//458 876//458 875//458 -f 873//459 871//459 874//459 876//459 -f 879//9 878//9 877//9 -f 883//10 884//10 885//10 -f 877//460 878//460 881//460 880//460 -f 878//461 879//461 882//461 881//461 -f 879//462 877//462 880//462 882//462 -f 880//454 881//454 884//454 883//454 -f 881//455 882//455 885//455 884//455 -f 882//456 880//456 883//456 885//456 +f 798//450 799//450 812//450 811//450 +f 799//306 800//306 813//306 812//306 +f 800//451 801//451 814//451 813//451 +f 804//452 805//452 818//452 817//452 +f 805//429 806//429 819//429 818//429 +f 806//453 807//453 820//453 819//453 +f 807//454 808//454 821//454 820//454 +f 808//306 796//306 809//306 821//306 +f 811//455 812//455 825//455 824//455 +f 812//317 813//317 826//317 825//317 +f 813//456 814//456 827//456 826//456 +f 814//317 815//317 828//317 827//317 +f 817//457 818//457 831//457 830//457 +f 818//432 819//432 832//432 831//432 +f 819//458 820//458 833//458 832//458 +f 820//459 821//459 834//459 833//459 +f 821//317 809//317 822//317 834//317 +f 796//9 808//9 807//9 +f 833//10 834//10 822//10 +f 796//9 807//9 806//9 +f 832//10 833//10 822//10 +f 796//9 806//9 805//9 +f 831//10 832//10 822//10 +f 796//9 805//9 804//9 +f 830//10 831//10 822//10 +f 796//9 804//9 803//9 +f 829//10 830//10 822//10 +f 873//9 872//9 871//9 +f 877//10 878//10 879//10 +f 871//460 872//460 875//460 874//460 +f 872//461 873//461 876//461 875//461 +f 873//462 871//462 874//462 876//462 +f 874//463 875//463 878//463 877//463 +f 875//464 876//464 879//464 878//464 +f 876//465 874//465 877//465 879//465 +f 882//9 881//9 880//9 +f 886//10 887//10 888//10 +f 880//466 881//466 884//466 883//466 +f 881//467 882//467 885//467 884//467 +f 882//468 880//468 883//468 885//468 +f 883//460 884//460 887//460 886//460 +f 884//461 885//461 888//461 887//461 +f 885//462 883//462 886//462 888//462 o foreLegL usemtl glass -f 886//463 887//463 893//463 892//463 -f 887//464 888//464 894//464 893//464 -f 891//465 886//465 892//465 897//465 -f 892//466 893//466 899//466 898//466 -f 895//467 896//467 902//467 901//467 -f 896//468 897//468 903//468 902//468 -f 897//469 892//469 898//469 903//469 -f 886//9 891//9 890//9 -f 902//10 903//10 898//10 -f 888//9 887//9 886//9 -f 898//10 899//10 900//10 -f 904//9 907//9 906//9 -f 914//10 915//10 912//10 -f 906//9 905//9 904//9 -f 912//10 913//10 914//10 -f 904//470 905//470 909//470 908//470 -f 905//471 906//471 910//471 909//471 -f 906//472 907//472 911//472 910//472 -f 907//473 904//473 908//473 911//473 -f 908//474 909//474 913//474 912//474 -f 909//475 910//475 914//475 913//475 -f 910//476 911//476 915//476 914//476 -f 911//477 908//477 912//477 915//477 -f 916//9 919//9 918//9 -f 926//10 927//10 924//10 -f 918//9 917//9 916//9 -f 924//10 925//10 926//10 -f 916//478 917//478 921//478 920//478 -f 917//479 918//479 922//479 921//479 -f 918//480 919//480 923//480 922//480 -f 919//481 916//481 920//481 923//481 -f 920//470 921//470 925//470 924//470 -f 921//471 922//471 926//471 925//471 -f 922//472 923//472 927//472 926//472 -f 923//473 920//473 924//473 927//473 +f 889//469 890//469 896//469 895//469 +f 890//470 891//470 897//470 896//470 +f 894//471 889//471 895//471 900//471 +f 895//472 896//472 902//472 901//472 +f 898//473 899//473 905//473 904//473 +f 899//474 900//474 906//474 905//474 +f 900//475 895//475 901//475 906//475 +f 889//9 894//9 893//9 +f 905//10 906//10 901//10 +f 891//9 890//9 889//9 +f 901//10 902//10 903//10 +f 907//9 910//9 909//9 +f 917//10 918//10 915//10 +f 909//9 908//9 907//9 +f 915//10 916//10 917//10 +f 907//476 908//476 912//476 911//476 +f 908//477 909//477 913//477 912//477 +f 909//478 910//478 914//478 913//478 +f 910//479 907//479 911//479 914//479 +f 911//480 912//480 916//480 915//480 +f 912//481 913//481 917//481 916//481 +f 913//482 914//482 918//482 917//482 +f 914//483 911//483 915//483 918//483 +f 919//9 922//9 921//9 +f 929//10 930//10 927//10 +f 921//9 920//9 919//9 +f 927//10 928//10 929//10 +f 919//484 920//484 924//484 923//484 +f 920//485 921//485 925//485 924//485 +f 921//486 922//486 926//486 925//486 +f 922//487 919//487 923//487 926//487 +f 923//476 924//476 928//476 927//476 +f 924//477 925//477 929//477 928//477 +f 925//478 926//478 930//478 929//478 +f 926//479 923//479 927//479 930//479 usemtl dark -f 888//482 889//482 895//482 894//482 -f 889//483 890//483 896//483 895//483 -f 890//484 891//484 897//484 896//484 -f 893//485 894//485 900//485 899//485 -f 894//486 895//486 901//486 900//486 -f 886//9 890//9 889//9 -f 901//10 902//10 898//10 -f 886//9 889//9 888//9 -f 900//10 901//10 898//10 -f 930//9 929//9 928//9 -f 934//10 935//10 936//10 -f 928//487 929//487 932//487 931//487 -f 929//488 930//488 933//488 932//488 -f 930//489 928//489 931//489 933//489 -f 931//490 932//490 935//490 934//490 -f 932//491 933//491 936//491 935//491 -f 933//492 931//492 934//492 936//492 -f 939//9 938//9 937//9 -f 943//10 944//10 945//10 -f 937//493 938//493 941//493 940//493 -f 938//494 939//494 942//494 941//494 -f 939//495 937//495 940//495 942//495 -f 940//487 941//487 944//487 943//487 -f 941//488 942//488 945//488 944//488 -f 942//489 940//489 943//489 945//489 +f 891//488 892//488 898//488 897//488 +f 892//489 893//489 899//489 898//489 +f 893//490 894//490 900//490 899//490 +f 896//491 897//491 903//491 902//491 +f 897//492 898//492 904//492 903//492 +f 889//9 893//9 892//9 +f 904//10 905//10 901//10 +f 889//9 892//9 891//9 +f 903//10 904//10 901//10 +f 933//9 932//9 931//9 +f 937//10 938//10 939//10 +f 931//493 932//493 935//493 934//493 +f 932//494 933//494 936//494 935//494 +f 933//495 931//495 934//495 936//495 +f 934//496 935//496 938//496 937//496 +f 935//497 936//497 939//497 938//497 +f 936//498 934//498 937//498 939//498 +f 942//9 941//9 940//9 +f 946//10 947//10 948//10 +f 940//499 941//499 944//499 943//499 +f 941//500 942//500 945//500 944//500 +f 942//501 940//501 943//501 945//501 +f 943//493 944//493 947//493 946//493 +f 944//494 945//494 948//494 947//494 +f 945//495 943//495 946//495 948//495 o hornL usemtl dark -f 948//9 947//9 946//9 -f 955//10 956//10 957//10 -f 946//496 947//496 950//496 949//496 -f 947//497 948//497 951//497 950//497 -f 948//498 946//498 949//498 951//498 -f 949//499 950//499 953//499 952//499 -f 950//500 951//500 954//500 953//500 -f 951//501 949//501 952//501 954//501 -f 952//502 953//502 956//502 955//502 -f 953//503 954//503 957//503 956//503 -f 954//504 952//504 955//504 957//504 +f 951//9 950//9 949//9 +f 958//10 959//10 960//10 +f 949//502 950//502 953//502 952//502 +f 950//503 951//503 954//503 953//503 +f 951//504 949//504 952//504 954//504 +f 952//505 953//505 956//505 955//505 +f 953//506 954//506 957//506 956//506 +f 954//507 952//507 955//507 957//507 +f 955//508 956//508 959//508 958//508 +f 956//509 957//509 960//509 959//509 +f 957//510 955//510 958//510 960//510 o hindLegR usemtl dark -f 958//421 959//421 972//421 971//421 -f 959//323 960//323 973//323 972//323 -f 963//323 964//323 977//323 976//323 -f 964//422 965//422 978//422 977//422 -f 965//423 966//423 979//423 978//423 -f 971//424 972//424 985//424 984//424 -f 972//334 973//334 986//334 985//334 -f 977//425 978//425 991//425 990//425 -f 978//426 979//426 992//426 991//426 -f 965//9 964//9 963//9 -f 989//10 990//10 991//10 -f 965//9 963//9 962//9 -f 988//10 989//10 991//10 -f 958//9 965//9 962//9 -f 988//10 991//10 984//10 -f 958//9 962//9 961//9 -f 987//10 988//10 984//10 -f 958//9 961//9 960//9 -f 986//10 987//10 984//10 -f 960//9 959//9 958//9 -f 984//10 985//10 986//10 -f 999//9 998//9 997//9 -f 1003//10 1004//10 1005//10 -f 997//427 998//427 1001//427 1000//427 -f 998//428 999//428 1002//428 1001//428 -f 999//429 997//429 1000//429 1002//429 -f 1000//430 1001//430 1004//430 1003//430 -f 1001//431 1002//431 1005//431 1004//431 -f 1002//432 1000//432 1003//432 1005//432 -f 1008//9 1007//9 1006//9 -f 1012//10 1013//10 1014//10 -f 1006//433 1007//433 1010//433 1009//433 -f 1007//434 1008//434 1011//434 1010//434 -f 1008//435 1006//435 1009//435 1011//435 -f 1009//427 1010//427 1013//427 1012//427 -f 1010//428 1011//428 1014//428 1013//428 -f 1011//429 1009//429 1012//429 1014//429 -f 1017//9 1016//9 1015//9 -f 1021//10 1022//10 1023//10 -f 1015//436 1016//436 1019//436 1018//436 -f 1016//29 1017//29 1020//29 1019//29 -f 1017//437 1015//437 1018//437 1020//437 -f 1018//438 1019//438 1022//438 1021//438 -f 1019//439 1020//439 1023//439 1022//439 -f 1020//440 1018//440 1021//440 1023//440 -f 1026//9 1025//9 1024//9 -f 1030//10 1031//10 1032//10 -f 1024//441 1025//441 1028//441 1027//441 -f 1025//442 1026//442 1029//442 1028//442 -f 1026//443 1024//443 1027//443 1029//443 -f 1027//436 1028//436 1031//436 1030//436 -f 1028//29 1029//29 1032//29 1031//29 -f 1029//437 1027//437 1030//437 1032//437 +f 961//427 962//427 975//427 974//427 +f 962//306 963//306 976//306 975//306 +f 966//306 967//306 980//306 979//306 +f 967//428 968//428 981//428 980//428 +f 968//429 969//429 982//429 981//429 +f 974//430 975//430 988//430 987//430 +f 975//317 976//317 989//317 988//317 +f 980//431 981//431 994//431 993//431 +f 981//432 982//432 995//432 994//432 +f 968//9 967//9 966//9 +f 992//10 993//10 994//10 +f 968//9 966//9 965//9 +f 991//10 992//10 994//10 +f 961//9 968//9 965//9 +f 991//10 994//10 987//10 +f 961//9 965//9 964//9 +f 990//10 991//10 987//10 +f 961//9 964//9 963//9 +f 989//10 990//10 987//10 +f 963//9 962//9 961//9 +f 987//10 988//10 989//10 +f 1002//9 1001//9 1000//9 +f 1006//10 1007//10 1008//10 +f 1000//433 1001//433 1004//433 1003//433 +f 1001//434 1002//434 1005//434 1004//434 +f 1002//435 1000//435 1003//435 1005//435 +f 1003//436 1004//436 1007//436 1006//436 +f 1004//437 1005//437 1008//437 1007//437 +f 1005//438 1003//438 1006//438 1008//438 +f 1011//9 1010//9 1009//9 +f 1015//10 1016//10 1017//10 +f 1009//439 1010//439 1013//439 1012//439 +f 1010//440 1011//440 1014//440 1013//440 +f 1011//441 1009//441 1012//441 1014//441 +f 1012//433 1013//433 1016//433 1015//433 +f 1013//434 1014//434 1017//434 1016//434 +f 1014//435 1012//435 1015//435 1017//435 +f 1020//9 1019//9 1018//9 +f 1024//10 1025//10 1026//10 +f 1018//442 1019//442 1022//442 1021//442 +f 1019//29 1020//29 1023//29 1022//29 +f 1020//443 1018//443 1021//443 1023//443 +f 1021//444 1022//444 1025//444 1024//444 +f 1022//445 1023//445 1026//445 1025//445 +f 1023//446 1021//446 1024//446 1026//446 +f 1029//9 1028//9 1027//9 +f 1033//10 1034//10 1035//10 +f 1027//447 1028//447 1031//447 1030//447 +f 1028//448 1029//448 1032//448 1031//448 +f 1029//449 1027//449 1030//449 1032//449 +f 1030//442 1031//442 1034//442 1033//442 +f 1031//29 1032//29 1035//29 1034//29 +f 1032//443 1030//443 1033//443 1035//443 usemtl champagne -f 960//444 961//444 974//444 973//444 -f 961//323 962//323 975//323 974//323 -f 962//445 963//445 976//445 975//445 -f 966//446 967//446 980//446 979//446 -f 967//423 968//423 981//423 980//423 -f 968//447 969//447 982//447 981//447 -f 969//448 970//448 983//448 982//448 -f 970//323 958//323 971//323 983//323 -f 973//449 974//449 987//449 986//449 -f 974//334 975//334 988//334 987//334 -f 975//450 976//450 989//450 988//450 -f 976//334 977//334 990//334 989//334 -f 979//451 980//451 993//451 992//451 -f 980//426 981//426 994//426 993//426 -f 981//452 982//452 995//452 994//452 -f 982//453 983//453 996//453 995//453 -f 983//334 971//334 984//334 996//334 -f 958//9 970//9 969//9 -f 995//10 996//10 984//10 -f 958//9 969//9 968//9 -f 994//10 995//10 984//10 -f 958//9 968//9 967//9 -f 993//10 994//10 984//10 -f 958//9 967//9 966//9 -f 992//10 993//10 984//10 -f 958//9 966//9 965//9 -f 991//10 992//10 984//10 -f 1035//9 1034//9 1033//9 -f 1039//10 1040//10 1041//10 -f 1033//454 1034//454 1037//454 1036//454 -f 1034//455 1035//455 1038//455 1037//455 -f 1035//456 1033//456 1036//456 1038//456 -f 1036//457 1037//457 1040//457 1039//457 -f 1037//458 1038//458 1041//458 1040//458 -f 1038//459 1036//459 1039//459 1041//459 -f 1044//9 1043//9 1042//9 -f 1048//10 1049//10 1050//10 -f 1042//460 1043//460 1046//460 1045//460 -f 1043//461 1044//461 1047//461 1046//461 -f 1044//462 1042//462 1045//462 1047//462 -f 1045//454 1046//454 1049//454 1048//454 -f 1046//455 1047//455 1050//455 1049//455 -f 1047//456 1045//456 1048//456 1050//456 +f 963//450 964//450 977//450 976//450 +f 964//306 965//306 978//306 977//306 +f 965//451 966//451 979//451 978//451 +f 969//452 970//452 983//452 982//452 +f 970//429 971//429 984//429 983//429 +f 971//453 972//453 985//453 984//453 +f 972//454 973//454 986//454 985//454 +f 973//306 961//306 974//306 986//306 +f 976//455 977//455 990//455 989//455 +f 977//317 978//317 991//317 990//317 +f 978//456 979//456 992//456 991//456 +f 979//317 980//317 993//317 992//317 +f 982//457 983//457 996//457 995//457 +f 983//432 984//432 997//432 996//432 +f 984//458 985//458 998//458 997//458 +f 985//459 986//459 999//459 998//459 +f 986//317 974//317 987//317 999//317 +f 961//9 973//9 972//9 +f 998//10 999//10 987//10 +f 961//9 972//9 971//9 +f 997//10 998//10 987//10 +f 961//9 971//9 970//9 +f 996//10 997//10 987//10 +f 961//9 970//9 969//9 +f 995//10 996//10 987//10 +f 961//9 969//9 968//9 +f 994//10 995//10 987//10 +f 1038//9 1037//9 1036//9 +f 1042//10 1043//10 1044//10 +f 1036//460 1037//460 1040//460 1039//460 +f 1037//461 1038//461 1041//461 1040//461 +f 1038//462 1036//462 1039//462 1041//462 +f 1039//463 1040//463 1043//463 1042//463 +f 1040//464 1041//464 1044//464 1043//464 +f 1041//465 1039//465 1042//465 1044//465 +f 1047//9 1046//9 1045//9 +f 1051//10 1052//10 1053//10 +f 1045//466 1046//466 1049//466 1048//466 +f 1046//467 1047//467 1050//467 1049//467 +f 1047//468 1045//468 1048//468 1050//468 +f 1048//460 1049//460 1052//460 1051//460 +f 1049//461 1050//461 1053//461 1052//461 +f 1050//462 1048//462 1051//462 1053//462 o foreLegR usemtl glass -f 1051//463 1052//463 1058//463 1057//463 -f 1052//464 1053//464 1059//464 1058//464 -f 1056//465 1051//465 1057//465 1062//465 -f 1057//466 1058//466 1064//466 1063//466 -f 1060//467 1061//467 1067//467 1066//467 -f 1061//468 1062//468 1068//468 1067//468 -f 1062//469 1057//469 1063//469 1068//469 -f 1051//9 1056//9 1055//9 -f 1067//10 1068//10 1063//10 -f 1053//9 1052//9 1051//9 -f 1063//10 1064//10 1065//10 -f 1069//9 1072//9 1071//9 -f 1079//10 1080//10 1077//10 -f 1071//9 1070//9 1069//9 -f 1077//10 1078//10 1079//10 -f 1069//470 1070//470 1074//470 1073//470 -f 1070//471 1071//471 1075//471 1074//471 -f 1071//472 1072//472 1076//472 1075//472 -f 1072//473 1069//473 1073//473 1076//473 -f 1073//474 1074//474 1078//474 1077//474 -f 1074//475 1075//475 1079//475 1078//475 -f 1075//476 1076//476 1080//476 1079//476 -f 1076//477 1073//477 1077//477 1080//477 -f 1081//9 1084//9 1083//9 -f 1091//10 1092//10 1089//10 -f 1083//9 1082//9 1081//9 -f 1089//10 1090//10 1091//10 -f 1081//478 1082//478 1086//478 1085//478 -f 1082//479 1083//479 1087//479 1086//479 -f 1083//480 1084//480 1088//480 1087//480 -f 1084//481 1081//481 1085//481 1088//481 -f 1085//470 1086//470 1090//470 1089//470 -f 1086//471 1087//471 1091//471 1090//471 -f 1087//472 1088//472 1092//472 1091//472 -f 1088//473 1085//473 1089//473 1092//473 +f 1054//469 1055//469 1061//469 1060//469 +f 1055//470 1056//470 1062//470 1061//470 +f 1059//471 1054//471 1060//471 1065//471 +f 1060//472 1061//472 1067//472 1066//472 +f 1063//473 1064//473 1070//473 1069//473 +f 1064//474 1065//474 1071//474 1070//474 +f 1065//475 1060//475 1066//475 1071//475 +f 1054//9 1059//9 1058//9 +f 1070//10 1071//10 1066//10 +f 1056//9 1055//9 1054//9 +f 1066//10 1067//10 1068//10 +f 1072//9 1075//9 1074//9 +f 1082//10 1083//10 1080//10 +f 1074//9 1073//9 1072//9 +f 1080//10 1081//10 1082//10 +f 1072//476 1073//476 1077//476 1076//476 +f 1073//477 1074//477 1078//477 1077//477 +f 1074//478 1075//478 1079//478 1078//478 +f 1075//479 1072//479 1076//479 1079//479 +f 1076//480 1077//480 1081//480 1080//480 +f 1077//481 1078//481 1082//481 1081//481 +f 1078//482 1079//482 1083//482 1082//482 +f 1079//483 1076//483 1080//483 1083//483 +f 1084//9 1087//9 1086//9 +f 1094//10 1095//10 1092//10 +f 1086//9 1085//9 1084//9 +f 1092//10 1093//10 1094//10 +f 1084//484 1085//484 1089//484 1088//484 +f 1085//485 1086//485 1090//485 1089//485 +f 1086//486 1087//486 1091//486 1090//486 +f 1087//487 1084//487 1088//487 1091//487 +f 1088//476 1089//476 1093//476 1092//476 +f 1089//477 1090//477 1094//477 1093//477 +f 1090//478 1091//478 1095//478 1094//478 +f 1091//479 1088//479 1092//479 1095//479 usemtl dark -f 1053//482 1054//482 1060//482 1059//482 -f 1054//483 1055//483 1061//483 1060//483 -f 1055//484 1056//484 1062//484 1061//484 -f 1058//485 1059//485 1065//485 1064//485 -f 1059//486 1060//486 1066//486 1065//486 -f 1051//9 1055//9 1054//9 -f 1066//10 1067//10 1063//10 -f 1051//9 1054//9 1053//9 -f 1065//10 1066//10 1063//10 -f 1095//9 1094//9 1093//9 -f 1099//10 1100//10 1101//10 -f 1093//487 1094//487 1097//487 1096//487 -f 1094//488 1095//488 1098//488 1097//488 -f 1095//489 1093//489 1096//489 1098//489 -f 1096//490 1097//490 1100//490 1099//490 -f 1097//491 1098//491 1101//491 1100//491 -f 1098//492 1096//492 1099//492 1101//492 -f 1104//9 1103//9 1102//9 -f 1108//10 1109//10 1110//10 -f 1102//493 1103//493 1106//493 1105//493 -f 1103//494 1104//494 1107//494 1106//494 -f 1104//495 1102//495 1105//495 1107//495 -f 1105//487 1106//487 1109//487 1108//487 -f 1106//488 1107//488 1110//488 1109//488 -f 1107//489 1105//489 1108//489 1110//489 +f 1056//488 1057//488 1063//488 1062//488 +f 1057//489 1058//489 1064//489 1063//489 +f 1058//490 1059//490 1065//490 1064//490 +f 1061//491 1062//491 1068//491 1067//491 +f 1062//492 1063//492 1069//492 1068//492 +f 1054//9 1058//9 1057//9 +f 1069//10 1070//10 1066//10 +f 1054//9 1057//9 1056//9 +f 1068//10 1069//10 1066//10 +f 1098//9 1097//9 1096//9 +f 1102//10 1103//10 1104//10 +f 1096//493 1097//493 1100//493 1099//493 +f 1097//494 1098//494 1101//494 1100//494 +f 1098//495 1096//495 1099//495 1101//495 +f 1099//496 1100//496 1103//496 1102//496 +f 1100//497 1101//497 1104//497 1103//497 +f 1101//498 1099//498 1102//498 1104//498 +f 1107//9 1106//9 1105//9 +f 1111//10 1112//10 1113//10 +f 1105//499 1106//499 1109//499 1108//499 +f 1106//500 1107//500 1110//500 1109//500 +f 1107//501 1105//501 1108//501 1110//501 +f 1108//493 1109//493 1112//493 1111//493 +f 1109//494 1110//494 1113//494 1112//494 +f 1110//495 1108//495 1111//495 1113//495 o hornR usemtl dark -f 1113//9 1112//9 1111//9 -f 1120//10 1121//10 1122//10 -f 1111//496 1112//496 1115//496 1114//496 -f 1112//497 1113//497 1116//497 1115//497 -f 1113//498 1111//498 1114//498 1116//498 -f 1114//499 1115//499 1118//499 1117//499 -f 1115//500 1116//500 1119//500 1118//500 -f 1116//501 1114//501 1117//501 1119//501 -f 1117//502 1118//502 1121//502 1120//502 -f 1118//503 1119//503 1122//503 1121//503 -f 1119//504 1117//504 1120//504 1122//504 +f 1116//9 1115//9 1114//9 +f 1123//10 1124//10 1125//10 +f 1114//502 1115//502 1118//502 1117//502 +f 1115//503 1116//503 1119//503 1118//503 +f 1116//504 1114//504 1117//504 1119//504 +f 1117//505 1118//505 1121//505 1120//505 +f 1118//506 1119//506 1122//506 1121//506 +f 1119//507 1117//507 1120//507 1122//507 +f 1120//508 1121//508 1124//508 1123//508 +f 1121//509 1122//509 1125//509 1124//509 +f 1122//510 1120//510 1123//510 1125//510 o crest usemtl champagne -f 1125//9 1124//9 1123//9 -f 1132//10 1133//10 1134//10 -f 1123//505 1124//505 1127//505 1126//505 -f 1124//506 1125//506 1128//506 1127//506 -f 1125//507 1123//507 1126//507 1128//507 -f 1126//508 1127//508 1130//508 1129//508 -f 1127//509 1128//509 1131//509 1130//509 -f 1128//510 1126//510 1129//510 1131//510 -f 1129//511 1130//511 1133//511 1132//511 -f 1130//512 1131//512 1134//512 1133//512 -f 1131//513 1129//513 1132//513 1134//513 +f 1128//9 1127//9 1126//9 +f 1135//10 1136//10 1137//10 +f 1126//511 1127//511 1130//511 1129//511 +f 1127//512 1128//512 1131//512 1130//512 +f 1128//513 1126//513 1129//513 1131//513 +f 1129//514 1130//514 1133//514 1132//514 +f 1130//515 1131//515 1134//515 1133//515 +f 1131//516 1129//516 1132//516 1134//516 +f 1132//517 1133//517 1136//517 1135//517 +f 1133//518 1134//518 1137//518 1136//518 +f 1134//519 1132//519 1135//519 1137//519 o wingL usemtl dark -f 1135//514 1138//514 1137//514 -f 1149//515 1150//515 1147//515 -f 1137//514 1136//514 1135//514 -f 1147//515 1148//515 1149//515 -f 1135//516 1136//516 1140//516 1139//516 -f 1136//517 1137//517 1141//517 1140//517 -f 1137//518 1138//518 1142//518 1141//518 -f 1138//519 1135//519 1139//519 1142//519 -f 1139//520 1140//520 1144//520 1143//520 -f 1140//521 1141//521 1145//521 1144//521 -f 1141//522 1142//522 1146//522 1145//522 -f 1142//523 1139//523 1143//523 1146//523 -f 1143//524 1144//524 1148//524 1147//524 -f 1144//525 1145//525 1149//525 1148//525 -f 1145//526 1146//526 1150//526 1149//526 -f 1146//527 1143//527 1147//527 1150//527 +f 1138//520 1141//520 1140//520 +f 1152//521 1153//521 1150//521 +f 1140//520 1139//520 1138//520 +f 1150//521 1151//521 1152//521 +f 1138//522 1139//522 1143//522 1142//522 +f 1139//523 1140//523 1144//523 1143//523 +f 1140//524 1141//524 1145//524 1144//524 +f 1141//525 1138//525 1142//525 1145//525 +f 1142//526 1143//526 1147//526 1146//526 +f 1143//527 1144//527 1148//527 1147//527 +f 1144//528 1145//528 1149//528 1148//528 +f 1145//529 1142//529 1146//529 1149//529 +f 1146//530 1147//530 1151//530 1150//530 +f 1147//531 1148//531 1152//531 1151//531 +f 1148//532 1149//532 1153//532 1152//532 +f 1149//533 1146//533 1150//533 1153//533 usemtl glass -f 1151//514 1154//514 1153//514 -f 1165//515 1166//515 1163//515 -f 1153//514 1152//514 1151//514 -f 1163//515 1164//515 1165//515 -f 1151//528 1152//528 1156//528 1155//528 -f 1152//529 1153//529 1157//529 1156//529 -f 1153//530 1154//530 1158//530 1157//530 -f 1154//531 1151//531 1155//531 1158//531 -f 1155//532 1156//532 1160//532 1159//532 -f 1156//533 1157//533 1161//533 1160//533 -f 1157//534 1158//534 1162//534 1161//534 -f 1158//535 1155//535 1159//535 1162//535 -f 1159//536 1160//536 1164//536 1163//536 -f 1160//537 1161//537 1165//537 1164//537 -f 1161//538 1162//538 1166//538 1165//538 -f 1162//539 1159//539 1163//539 1166//539 -f 1167//540 1170//540 1169//540 -f 1181//541 1182//541 1179//541 -f 1169//514 1168//514 1167//514 -f 1179//515 1180//515 1181//515 -f 1167//542 1168//542 1172//542 1171//542 -f 1168//543 1169//543 1173//543 1172//543 -f 1169//544 1170//544 1174//544 1173//544 -f 1170//545 1167//545 1171//545 1174//545 -f 1171//546 1172//546 1176//546 1175//546 -f 1172//547 1173//547 1177//547 1176//547 -f 1173//548 1174//548 1178//548 1177//548 -f 1174//549 1171//549 1175//549 1178//549 -f 1175//550 1176//550 1180//550 1179//550 -f 1176//551 1177//551 1181//551 1180//551 -f 1177//552 1178//552 1182//552 1181//552 -f 1178//553 1175//553 1179//553 1182//553 +f 1154//520 1157//520 1156//520 +f 1168//521 1169//521 1166//521 +f 1156//520 1155//520 1154//520 +f 1166//521 1167//521 1168//521 +f 1154//534 1155//534 1159//534 1158//534 +f 1155//535 1156//535 1160//535 1159//535 +f 1156//536 1157//536 1161//536 1160//536 +f 1157//537 1154//537 1158//537 1161//537 +f 1158//538 1159//538 1163//538 1162//538 +f 1159//539 1160//539 1164//539 1163//539 +f 1160//540 1161//540 1165//540 1164//540 +f 1161//541 1158//541 1162//541 1165//541 +f 1162//542 1163//542 1167//542 1166//542 +f 1163//543 1164//543 1168//543 1167//543 +f 1164//544 1165//544 1169//544 1168//544 +f 1165//545 1162//545 1166//545 1169//545 +f 1170//546 1173//546 1172//546 +f 1184//547 1185//547 1182//547 +f 1172//520 1171//520 1170//520 +f 1182//521 1183//521 1184//521 +f 1170//548 1171//548 1175//548 1174//548 +f 1171//549 1172//549 1176//549 1175//549 +f 1172//550 1173//550 1177//550 1176//550 +f 1173//551 1170//551 1174//551 1177//551 +f 1174//552 1175//552 1179//552 1178//552 +f 1175//553 1176//553 1180//553 1179//553 +f 1176//554 1177//554 1181//554 1180//554 +f 1177//555 1174//555 1178//555 1181//555 +f 1178//556 1179//556 1183//556 1182//556 +f 1179//557 1180//557 1184//557 1183//557 +f 1180//558 1181//558 1185//558 1184//558 +f 1181//559 1178//559 1182//559 1185//559 o wingR usemtl dark -f 1183//554 1186//554 1185//554 -f 1197//555 1198//555 1195//555 -f 1185//554 1184//554 1183//554 -f 1195//555 1196//555 1197//555 -f 1183//556 1184//556 1188//556 1187//556 -f 1184//557 1185//557 1189//557 1188//557 -f 1185//558 1186//558 1190//558 1189//558 -f 1186//559 1183//559 1187//559 1190//559 -f 1187//560 1188//560 1192//560 1191//560 -f 1188//561 1189//561 1193//561 1192//561 -f 1189//562 1190//562 1194//562 1193//562 -f 1190//563 1187//563 1191//563 1194//563 -f 1191//564 1192//564 1196//564 1195//564 -f 1192//565 1193//565 1197//565 1196//565 -f 1193//566 1194//566 1198//566 1197//566 -f 1194//567 1191//567 1195//567 1198//567 +f 1186//560 1189//560 1188//560 +f 1200//561 1201//561 1198//561 +f 1188//560 1187//560 1186//560 +f 1198//561 1199//561 1200//561 +f 1186//562 1187//562 1191//562 1190//562 +f 1187//563 1188//563 1192//563 1191//563 +f 1188//564 1189//564 1193//564 1192//564 +f 1189//565 1186//565 1190//565 1193//565 +f 1190//566 1191//566 1195//566 1194//566 +f 1191//567 1192//567 1196//567 1195//567 +f 1192//568 1193//568 1197//568 1196//568 +f 1193//569 1190//569 1194//569 1197//569 +f 1194//570 1195//570 1199//570 1198//570 +f 1195//571 1196//571 1200//571 1199//571 +f 1196//572 1197//572 1201//572 1200//572 +f 1197//573 1194//573 1198//573 1201//573 usemtl glass -f 1199//554 1202//554 1201//554 -f 1213//555 1214//555 1211//555 -f 1201//554 1200//554 1199//554 -f 1211//555 1212//555 1213//555 -f 1199//568 1200//568 1204//568 1203//568 -f 1200//569 1201//569 1205//569 1204//569 -f 1201//570 1202//570 1206//570 1205//570 -f 1202//571 1199//571 1203//571 1206//571 -f 1203//572 1204//572 1208//572 1207//572 -f 1204//573 1205//573 1209//573 1208//573 -f 1205//574 1206//574 1210//574 1209//574 -f 1206//575 1203//575 1207//575 1210//575 -f 1207//576 1208//576 1212//576 1211//576 -f 1208//577 1209//577 1213//577 1212//577 -f 1209//578 1210//578 1214//578 1213//578 -f 1210//579 1207//579 1211//579 1214//579 -f 1215//580 1218//580 1217//580 -f 1229//581 1230//581 1227//581 -f 1217//554 1216//554 1215//554 -f 1227//555 1228//555 1229//555 -f 1215//582 1216//582 1220//582 1219//582 -f 1216//583 1217//583 1221//583 1220//583 -f 1217//584 1218//584 1222//584 1221//584 -f 1218//585 1215//585 1219//585 1222//585 -f 1219//586 1220//586 1224//586 1223//586 -f 1220//587 1221//587 1225//587 1224//587 -f 1221//588 1222//588 1226//588 1225//588 -f 1222//589 1219//589 1223//589 1226//589 -f 1223//590 1224//590 1228//590 1227//590 -f 1224//591 1225//591 1229//591 1228//591 -f 1225//592 1226//592 1230//592 1229//592 -f 1226//593 1223//593 1227//593 1230//593 +f 1202//560 1205//560 1204//560 +f 1216//561 1217//561 1214//561 +f 1204//560 1203//560 1202//560 +f 1214//561 1215//561 1216//561 +f 1202//574 1203//574 1207//574 1206//574 +f 1203//575 1204//575 1208//575 1207//575 +f 1204//576 1205//576 1209//576 1208//576 +f 1205//577 1202//577 1206//577 1209//577 +f 1206//578 1207//578 1211//578 1210//578 +f 1207//579 1208//579 1212//579 1211//579 +f 1208//580 1209//580 1213//580 1212//580 +f 1209//581 1206//581 1210//581 1213//581 +f 1210//582 1211//582 1215//582 1214//582 +f 1211//583 1212//583 1216//583 1215//583 +f 1212//584 1213//584 1217//584 1216//584 +f 1213//585 1210//585 1214//585 1217//585 +f 1218//586 1221//586 1220//586 +f 1232//587 1233//587 1230//587 +f 1220//560 1219//560 1218//560 +f 1230//561 1231//561 1232//561 +f 1218//588 1219//588 1223//588 1222//588 +f 1219//589 1220//589 1224//589 1223//589 +f 1220//590 1221//590 1225//590 1224//590 +f 1221//591 1218//591 1222//591 1225//591 +f 1222//592 1223//592 1227//592 1226//592 +f 1223//593 1224//593 1228//593 1227//593 +f 1224//594 1225//594 1229//594 1228//594 +f 1225//595 1222//595 1226//595 1229//595 +f 1226//596 1227//596 1231//596 1230//596 +f 1227//597 1228//597 1232//597 1231//597 +f 1228//598 1229//598 1233//598 1232//598 +f 1229//599 1226//599 1230//599 1233//599 diff --git a/assets/DragonPivots.json b/media/DragonPivots.json similarity index 96% rename from assets/DragonPivots.json rename to media/DragonPivots.json index 9bf13ac75..30fee4dad 100644 --- a/assets/DragonPivots.json +++ b/media/DragonPivots.json @@ -10,6 +10,7 @@ ], "head": ["min", "mid", "mid"], + "jaw": ["min", "max", "mid"], "neck": ["mid", "min", "mid"], "tail": ["max", "mid", "mid"], diff --git a/assets/DragonPrint-champagne.stl b/media/DragonPrint-champagne.stl similarity index 100% rename from assets/DragonPrint-champagne.stl rename to media/DragonPrint-champagne.stl diff --git a/assets/DragonPrint-dark.stl b/media/DragonPrint-dark.stl similarity index 100% rename from assets/DragonPrint-dark.stl rename to media/DragonPrint-dark.stl diff --git a/assets/DragonPrint-frame.stl b/media/DragonPrint-frame.stl similarity index 100% rename from assets/DragonPrint-frame.stl rename to media/DragonPrint-frame.stl diff --git a/assets/DragonPrint-glass.stl b/media/DragonPrint-glass.stl similarity index 100% rename from assets/DragonPrint-glass.stl rename to media/DragonPrint-glass.stl diff --git a/assets/DragonPrint.mtl b/media/DragonPrint.mtl similarity index 100% rename from assets/DragonPrint.mtl rename to media/DragonPrint.mtl diff --git a/assets/DragonPrint.obj b/media/DragonPrint.obj similarity index 100% rename from assets/DragonPrint.obj rename to media/DragonPrint.obj diff --git a/assets/DragonRelief.glb b/media/DragonRelief.glb similarity index 100% rename from assets/DragonRelief.glb rename to media/DragonRelief.glb diff --git a/assets/Plaque-champagne.stl b/media/Plaque-champagne.stl similarity index 100% rename from assets/Plaque-champagne.stl rename to media/Plaque-champagne.stl diff --git a/assets/Plaque-dark.stl b/media/Plaque-dark.stl similarity index 100% rename from assets/Plaque-dark.stl rename to media/Plaque-dark.stl diff --git a/assets/Plaque-frame.stl b/media/Plaque-frame.stl similarity index 100% rename from assets/Plaque-frame.stl rename to media/Plaque-frame.stl diff --git a/assets/Plaque-glass.stl b/media/Plaque-glass.stl similarity index 100% rename from assets/Plaque-glass.stl rename to media/Plaque-glass.stl diff --git a/assets/Plaque-plate.stl b/media/Plaque-plate.stl similarity index 100% rename from assets/Plaque-plate.stl rename to media/Plaque-plate.stl diff --git a/assets/Plaque.mtl b/media/Plaque.mtl similarity index 100% rename from assets/Plaque.mtl rename to media/Plaque.mtl diff --git a/assets/Plaque.obj b/media/Plaque.obj similarity index 100% rename from assets/Plaque.obj rename to media/Plaque.obj diff --git a/assets/Service.singe b/media/Service.singe similarity index 100% rename from assets/Service.singe rename to media/Service.singe diff --git a/assets/SingeText.jpeg b/media/SingeText.jpeg similarity index 100% rename from assets/SingeText.jpeg rename to media/SingeText.jpeg diff --git a/assets/SingeText.mtl b/media/SingeText.mtl similarity index 100% rename from assets/SingeText.mtl rename to media/SingeText.mtl diff --git a/assets/SingeText.obj b/media/SingeText.obj similarity index 100% rename from assets/SingeText.obj rename to media/SingeText.obj diff --git a/assets/SingeText.svg b/media/SingeText.svg similarity index 100% rename from assets/SingeText.svg rename to media/SingeText.svg diff --git a/assets/SingeTextPrint-face.stl b/media/SingeTextPrint-face.stl similarity index 100% rename from assets/SingeTextPrint-face.stl rename to media/SingeTextPrint-face.stl diff --git a/assets/SingeTextPrint-plate.stl b/media/SingeTextPrint-plate.stl similarity index 100% rename from assets/SingeTextPrint-plate.stl rename to media/SingeTextPrint-plate.stl diff --git a/assets/SingeTextPrint-walls.stl b/media/SingeTextPrint-walls.stl similarity index 100% rename from assets/SingeTextPrint-walls.stl rename to media/SingeTextPrint-walls.stl diff --git a/assets/SingeTextPrint.mtl b/media/SingeTextPrint.mtl similarity index 100% rename from assets/SingeTextPrint.mtl rename to media/SingeTextPrint.mtl diff --git a/assets/SingeTextPrint.obj b/media/SingeTextPrint.obj similarity index 100% rename from assets/SingeTextPrint.obj rename to media/SingeTextPrint.obj diff --git a/media/music/eliza_music-neons-in-napoli-539381.mp3 b/media/music/eliza_music-neons-in-napoli-539381.mp3 new file mode 100644 index 000000000..550661787 --- /dev/null +++ b/media/music/eliza_music-neons-in-napoli-539381.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e30b234828f03931d13ec58de4afd64537d79e5952d35924b9b2dfcf7417af5 +size 6026496 diff --git a/media/music/miiira-vintage-funky-groove-90s-dance-club-beat-533491.mp3 b/media/music/miiira-vintage-funky-groove-90s-dance-club-beat-533491.mp3 new file mode 100644 index 000000000..97e06bd30 --- /dev/null +++ b/media/music/miiira-vintage-funky-groove-90s-dance-club-beat-533491.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc6c875acb922ece44175a185e0744f9fab257df21bf400b1df7338f3b7364dd +size 7837440 diff --git a/media/music/poorartistt-game-is-on-video-game-music-no-copyright-426130.mp3 b/media/music/poorartistt-game-is-on-video-game-music-no-copyright-426130.mp3 new file mode 100644 index 000000000..1cb0d74de --- /dev/null +++ b/media/music/poorartistt-game-is-on-video-game-music-no-copyright-426130.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee7b2aab6dd964d89861e5846aadb1cfe5cd5a4674d7727724c12fbd0d2d01c6 +size 7993344 diff --git a/assets/samples/dragonFireBreathAndRoar.flac b/media/samples/dragonFireBreathAndRoar.flac similarity index 100% rename from assets/samples/dragonFireBreathAndRoar.flac rename to media/samples/dragonFireBreathAndRoar.flac diff --git a/assets/samples/explosionUltraBass.flac b/media/samples/explosionUltraBass.flac similarity index 100% rename from assets/samples/explosionUltraBass.flac rename to media/samples/explosionUltraBass.flac diff --git a/assets/samples/largeFireball.flac b/media/samples/largeFireball.flac similarity index 100% rename from assets/samples/largeFireball.flac rename to media/samples/largeFireball.flac diff --git a/src/gui.cpp b/src/gui.cpp index 82e943e2e..946cb0f0c 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -66,6 +66,9 @@ typedef struct GuiContextS { int32_t height; bool inputEnabled; bool used; + bool mustDraw; // Draw next frame whatever the signature says: new, or guiInvalidate + uint64_t signature; // The last frame drawn, as GuiRenderT signs it + uint64_t generation; // What the render interface had made by then char name[GUI_NAME_MAX]; } GuiContextT; @@ -470,24 +473,23 @@ static void _releaseContext(GuiContextT *context) { } -// Clears the context's texture to transparent black, then lets RmlUi draw the context over it. +// Lets RmlUi draw the context into its texture, from transparent black. RmlUi describes the whole +// picture every frame, but a HUD or a menu is the same picture most of them: when this frame's +// drawing signs the same as the last one drawn and nothing new was made since, the texture already +// holds it and the GPU is spared the work. Anything that changes the picture behind RmlUi's back +// calls guiInvalidate. static void _renderContext(GuiContextT *context, SDL_GPUCommandBuffer *commands) { - SDL_GPUColorTargetInfo target = {}; - SDL_GPURenderPass *pass = nullptr; + bool draw = false; - target.texture = context->target; - target.load_op = SDL_GPU_LOADOP_CLEAR; - target.store_op = SDL_GPU_STOREOP_STORE; - target.clear_color = { 0.0f, 0.0f, 0.0f, 0.0f }; - pass = rgpuBeginRenderPass(commands, &target, 1, nullptr); - if (pass == nullptr) { - utilTrace("Gui: %s", SDL_GetError()); - return; - } - rgpuEndRenderPass(pass); _render->BeginFrame(commands, context->target, context->stencil, (uint32_t)context->width, (uint32_t)context->height); context->context->Render(); - _render->EndFrame(); + draw = context->mustDraw || (_render->GetSignature() != context->signature) || (_render->GetGeneration() != context->generation); + if (draw) { + context->signature = _render->GetSignature(); + context->generation = _render->GetGeneration(); + context->mustDraw = false; + } + _render->EndFrame(draw); } @@ -697,6 +699,20 @@ bool guiInit(SDL_GPUDevice *device, SDL_Renderer *renderer, SDL_Window *window) } +// The GUI draws next frame even if RmlUi describes the same picture: for anything that changes what +// it shows without RmlUi knowing. False for a GUI that does not exist. +bool guiInvalidate(int32_t gui) { + GuiContextT *context = _contextOf(gui); + + if (context == nullptr) { + return false; + } + context->mustDraw = true; + + return true; +} + + bool guiKey(int32_t gui, SDL_Keycode key, SDL_Keymod modifiers, bool down) { GuiContextT *context = _inputContextOf(gui); Rml::Input::KeyIdentifier identifier = _keyOf(key); @@ -870,6 +886,7 @@ int32_t guiNew(int32_t width, int32_t height) { context->width = width; context->height = height; context->inputEnabled = false; + context->mustDraw = true; info.type = SDL_GPU_TEXTURETYPE_2D; info.format = _targetFormat; info.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER; diff --git a/src/gui.h b/src/gui.h index ab369a183..aabda9c2b 100644 --- a/src/gui.h +++ b/src/gui.h @@ -73,6 +73,7 @@ bool guiDelete(int32_t gui); int32_t guiGetHeight(int32_t gui); void guiGetStats(GuiStatsT *stats); // The last completed frame's rendering counters int32_t guiGetWidth(int32_t gui); +bool guiInvalidate(int32_t gui); // Draw next frame even when RmlUi describes the same picture int32_t guiNew(int32_t width, int32_t height); // GUI_NO_HANDLE when full or without a GPU SDL_GPUTexture *guiTexture(int32_t gui); // For materials; NULL when invalid SDL_Texture *guiTexture2D(int32_t gui); // The same as the 2D renderer sees it (premultiplied alpha) diff --git a/src/guiRender.cpp b/src/guiRender.cpp index c2cb5f86b..280af3311 100644 --- a/src/guiRender.cpp +++ b/src/guiRender.cpp @@ -93,6 +93,8 @@ extern "C" { #define MAX_LAYERS 8 // Layers stacked over a context's target at once #define FILTER_BUFFERS 2 // The ping-pong pair a filter chain runs through #define BLUR_WEIGHTS 4 // The centre tap and the three beyond it each side (BLUR_TAPS = 7, in gui.hlsl) +#define FNV_OFFSET 14695981039346656037ULL // FNV-1a, which the frame's signature is +#define FNV_PRIME 1099511628211ULL #define BLUR_MAX_LEVELS 10 // Halvings before a blur, at most #define BLUR_MAX_SIGMA 3.0f // The widest sigma one pass of BLUR_TAPS taps handles #define BLUR_MIN_SIGMA 0.1f // Below this the blur is the identity @@ -104,6 +106,23 @@ extern "C" { #define LUMA_BLUE 0.0722f #define TWO_PI_SQRT 2.5066283f +// What each drawing command adds to the frame's signature ahead of its values, so two different +// commands with the same values never sign alike. +enum GuiCommandE : uint8_t { + COMMAND_CLIP_MASK, + COMMAND_COMPOSITE, + COMMAND_GEOMETRY, + COMMAND_POP_LAYER, + COMMAND_PUSH_LAYER, + COMMAND_SAVE_MASK, + COMMAND_SAVE_TEXTURE, + COMMAND_SCISSOR, + COMMAND_SCISSOR_ENABLE, + COMMAND_SHADER, + COMMAND_TO_CLIP_MASK, + COMMAND_TRANSFORM +}; + // The fragment shaders, as the pipeline table names them. typedef enum GuiFragmentE { @@ -464,6 +483,8 @@ void GuiRenderT::BeginFrame(SDL_GPUCommandBuffer *frameCommands, SDL_GPUTexture recordDepth = 0; refusedDepth = 0; recordScissor = { 0, 0, (int)width, (int)height }; + signature = FNV_OFFSET; + signatureBytes = 0; proj = Rml::Matrix4f::ProjectOrtho(0.0f, (float)width, (float)height, 0.0f, ORTHO_NEAR, ORTHO_FAR); if (screenQuad == nullptr) { screenQuad = uploadGeometry(_screenQuadVertices, sizeof(_screenQuadVertices), _screenQuadIndices, sizeof(_screenQuadIndices)); @@ -486,6 +507,7 @@ Rml::CompiledFilterHandle GuiRenderT::CompileFilter(const Rml::String &name, con Rml::Vector3f mix; Rml::Vector3f green; Rml::Vector3f blue; + generation++; filter->type = GUI_FILTER_OPACITY; filter->factor = 1.0f; @@ -560,6 +582,7 @@ Rml::CompiledFilterHandle GuiRenderT::CompileFilter(const Rml::String &name, con Rml::CompiledGeometryHandle GuiRenderT::CompileGeometry(Rml::Span vertices, Rml::Span indices) { GeometryT *geometry = uploadGeometry(vertices.data(), (uint32_t)(vertices.size() * sizeof(Rml::Vertex)), indices.data(), (uint32_t)(indices.size() * sizeof(int))); + generation++; return reinterpret_cast(geometry); } @@ -578,6 +601,7 @@ Rml::CompiledShaderHandle GuiRenderT::CompileShader(const Rml::String &name, con float angle = 0.0f; int32_t kind = GUI_GRADIENT_LINEAR; int32_t i = 0; + generation++; if (name == "linear-gradient") { kind = GUI_GRADIENT_LINEAR; @@ -628,11 +652,20 @@ Rml::CompiledShaderHandle GuiRenderT::CompileShader(const Rml::String &name, con void GuiRenderT::CompositeLayers(Rml::LayerHandle source, Rml::LayerHandle destination, Rml::BlendMode blendMode, Rml::Span filters) { + sign(COMMAND_COMPOSITE); + sign(source); + sign(destination); + sign(blendMode); + for (Rml::CompiledFilterHandle filter : filters) { + sign(filter); + } commands.push_back(Rml::MakeUnique(source, destination, blendMode, filters)); } void GuiRenderT::EnableClipMask(bool enable) { + sign(COMMAND_CLIP_MASK); + sign(enable); commands.push_back(Rml::MakeUnique(enable)); } @@ -641,13 +674,26 @@ void GuiRenderT::EnableScissorRegion(bool enable) { if (!enable) { recordScissor = { 0, 0, (int)targetWidth, (int)targetHeight }; } + sign(COMMAND_SCISSOR_ENABLE); + sign(enable); commands.push_back(Rml::MakeUnique(enable)); } -void GuiRenderT::EndFrame() { +void GuiRenderT::EndFrame(bool draw) { + // Drawing starts from a cleared target, in a pass of its own that also clears the stencil. + if (draw) { + target = baseTarget; + targetClear = true; + if (beginRenderPass()) { + rgpuEndRenderPass(renderPass); + renderPass = nullptr; + } + } for (Rml::UniquePtr &command : commands) { - command->Run(*this); + if (draw || command->Releases()) { + command->Run(*this); + } } commands.clear(); if (copyPass != nullptr) { @@ -678,6 +724,7 @@ Rml::TextureHandle GuiRenderT::GenerateTexture(Rml::Span source SDL_GPUCopyPass *pass = nullptr; uint32_t size = (uint32_t)(sourceDimensions.x * sourceDimensions.y * BYTES_PER_PIXEL); void *destination = nullptr; + generation++; transferInfo.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; transferInfo.size = size; @@ -745,6 +792,18 @@ void GuiRenderT::GetStats(GuiStatsT *outStats) const { } +uint64_t GuiRenderT::GetGeneration() const { + return generation; +} + + +// The byte count goes in too, so a frame that is the last one's with something left off the end +// still signs differently. +uint64_t GuiRenderT::GetSignature() const { + return signature ^ (signatureBytes * FNV_PRIME); +} + + GuiRenderT::GuiRenderT(SDL_GPUDevice *gpuDevice, SDL_Window *gpuWindow) { SDL_GPUSamplerCreateInfo info = {}; size_t i = 0; @@ -828,6 +887,7 @@ Rml::TextureHandle GuiRenderT::LoadTexture(Rml::Vector2i &textureDimensions, con size_t dot = source.rfind('.'); Rml::String extension = (dot == Rml::String::npos) ? Rml::String() : source.substr(dot + 1); Rml::TextureHandle handle = 0; + generation++; if (!file) { return 0; @@ -897,6 +957,7 @@ void GuiRenderT::PopLayer() { if (recordDepth > 0) { recordDepth--; } + sign(COMMAND_POP_LAYER); commands.push_back(Rml::MakeUnique()); } @@ -913,6 +974,7 @@ Rml::LayerHandle GuiRenderT::PushLayer() { return (Rml::LayerHandle)recordDepth; } recordDepth++; + sign(COMMAND_PUSH_LAYER); commands.push_back(Rml::MakeUnique()); return (Rml::LayerHandle)recordDepth; } @@ -963,6 +1025,10 @@ void GuiRenderT::ReleaseTexture(Rml::TextureHandle texture) { void GuiRenderT::RenderGeometry(Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation, Rml::TextureHandle texture) { + sign(COMMAND_GEOMETRY); + sign(geometry); + sign(translation); + sign(texture); commands.push_back(Rml::MakeUnique(geometry, translation, texture)); } @@ -973,11 +1039,19 @@ void GuiRenderT::RenderShader(Rml::CompiledShaderHandle shader, Rml::CompiledGeo if ((shader == 0) || (geometry == 0)) { return; } + sign(COMMAND_SHADER); + sign(shader); + sign(geometry); + sign(translation); commands.push_back(Rml::MakeUnique(reinterpret_cast(shader), geometry, translation)); } void GuiRenderT::RenderToClipMask(Rml::ClipMaskOperation operation, Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation) { + sign(COMMAND_TO_CLIP_MASK); + sign(operation); + sign(geometry); + sign(translation); commands.push_back(Rml::MakeUnique(operation, geometry, translation)); } @@ -997,6 +1071,9 @@ Rml::CompiledFilterHandle GuiRenderT::SaveLayerAsMaskImage() { filter->matrix = Rml::Matrix4f::Identity(); filter->mask = nullptr; memset(filter->colour, 0, sizeof(filter->colour)); + generation++; + sign(COMMAND_SAVE_MASK); + sign(filter); commands.push_back(Rml::MakeUnique(filter)); return reinterpret_cast(filter); } @@ -1024,6 +1101,10 @@ Rml::TextureHandle GuiRenderT::SaveLayerAsTexture() { Rml::Log::Message(Rml::Log::LT_ERROR, "failed to create layer texture: %s", SDL_GetError()); return 0; } + generation++; + sign(COMMAND_SAVE_TEXTURE); + sign(texture); + sign(region); commands.push_back(Rml::MakeUnique(texture, region)); return reinterpret_cast(texture); } @@ -1034,11 +1115,17 @@ void GuiRenderT::SetScissorRegion(Rml::Rectanglei region) { recordScissor.y = region.Top(); recordScissor.w = region.Width(); recordScissor.h = region.Height(); + sign(COMMAND_SCISSOR); + sign(region); commands.push_back(Rml::MakeUnique(region)); } void GuiRenderT::SetTransform(const Rml::Matrix4f *newTransform) { + sign(COMMAND_TRANSFORM); + if (newTransform != nullptr) { + sign(*newTransform); + } commands.push_back(Rml::MakeUnique(newTransform)); } @@ -1555,6 +1642,18 @@ void GuiRenderT::setTarget(SDL_GPUTexture *texture, bool clear) { } +// Adds bytes to the frame's signature (FNV-1a). +void GuiRenderT::sign(const void *bytes, size_t count) { + const uint8_t *in = (const uint8_t *)bytes; + size_t x = 0; + + for (x = 0; x < count; x++) { + signature = (signature ^ in[x]) * FNV_PRIME; + } + signatureBytes += count; +} + + // Vertices and indices into pooled buffers through the frame's copy pass. GuiRenderT::GeometryT *GuiRenderT::uploadGeometry(const void *vertexData, uint32_t vertexSize, const void *indexData, uint32_t indexSize) { SDL_GPUTransferBufferLocation location = {}; @@ -1587,6 +1686,9 @@ GuiRenderT::GeometryT *GuiRenderT::uploadGeometry(const void *vertexData, uint32 delete geometry; return nullptr; } + // Cycled: a pooled buffer came back from geometry that is gone, so nothing in it is wanted, while + // the last frame's draws may still be reading it. Writing over it in place makes a tiler wait for + // them or copy the buffer first; cycling hands the upload fresh storage instead. memcpy(vertexStage, vertexData, vertexSize); memcpy(indexStage, indexData, indexSize); rgpuUnmapTransferBuffer(device, geometry->vertices->transfer); @@ -1594,11 +1696,11 @@ GuiRenderT::GeometryT *GuiRenderT::uploadGeometry(const void *vertexData, uint32 location.transfer_buffer = geometry->vertices->transfer; region.buffer = geometry->vertices->buffer; region.size = vertexSize; - rgpuUploadToBuffer(copyPass, &location, ®ion, false); + rgpuUploadToBuffer(copyPass, &location, ®ion, true); location.transfer_buffer = geometry->indices->transfer; region.buffer = geometry->indices->buffer; region.size = indexSize; - rgpuUploadToBuffer(copyPass, &location, ®ion, false); + rgpuUploadToBuffer(copyPass, &location, ®ion, true); geometry->indexCount = (int32_t)(indexSize / sizeof(int32_t)); geometry->vertices->inUse = true; geometry->indices->inUse = true; diff --git a/src/guiRender.h b/src/guiRender.h index 7397de39c..13ea3d946 100644 --- a/src/guiRender.h +++ b/src/guiRender.h @@ -94,7 +94,9 @@ public: // A frame renders one context: its colour target (already cleared by the caller), its // stencil texture in StencilFormat() (or nullptr when that is INVALID) and its size. void BeginFrame(SDL_GPUCommandBuffer *frameCommands, SDL_GPUTexture *frameTarget, SDL_GPUTexture *frameStencil, uint32_t width, uint32_t height); - void EndFrame(); + void EndFrame(bool draw); // False keeps last frame's picture: only resources are released + uint64_t GetGeneration() const; // Counts everything made: a change means the next frame must draw + uint64_t GetSignature() const; // The frame's drawing, hashed: the same as last frame's draws the same picture void GetStats(GuiStatsT *stats) const; // Counted since ResetStats, plus the pool's bytes now void ReleaseLayers(uint32_t width, uint32_t height); // Drops pooled layers of a context's size (when it goes) void ResetStats(); @@ -209,6 +211,7 @@ private: struct CommandT { virtual ~CommandT() = default; virtual void Run(GuiRenderT &render) = 0; + virtual bool Releases() const { return false; } // Frees something, so it runs even on a frame that does not draw }; struct CompositeLayersCommandT : CommandT { @@ -246,6 +249,7 @@ private: struct ReleaseFilterCommandT : CommandT { ReleaseFilterCommandT(FilterT *filter) : filter(filter) {} void Run(GuiRenderT &render) override; + bool Releases() const override { return true; } FilterT *filter; }; @@ -253,6 +257,7 @@ private: struct ReleaseGeometryCommandT : CommandT { ReleaseGeometryCommandT(Rml::CompiledGeometryHandle handle) : handle(handle) {} void Run(GuiRenderT &render) override; + bool Releases() const override { return true; } Rml::CompiledGeometryHandle handle; }; @@ -260,6 +265,7 @@ private: struct ReleaseShaderCommandT : CommandT { ReleaseShaderCommandT(ShaderT *shader) : shader(shader) {} void Run(GuiRenderT &render) override; + bool Releases() const override { return true; } ShaderT *shader; }; @@ -267,6 +273,7 @@ private: struct ReleaseTextureCommandT : CommandT { ReleaseTextureCommandT(Rml::TextureHandle handle) : handle(handle) {} void Run(GuiRenderT &render) override; + bool Releases() const override { return true; } Rml::TextureHandle handle; }; @@ -346,6 +353,8 @@ private: friend struct SetTransformCommandT; TargetT *acquireTarget(); + void sign(const void *bytes, size_t count); + template void sign(const T &value) { sign(&value, sizeof(value)); } bool beginCopyPass(); bool beginRenderPass(); void blitTarget(SDL_GPUTexture *source, SDL_Rect from, SDL_GPUTexture *destination, SDL_Rect to); @@ -392,6 +401,9 @@ private: SDL_GPURenderPass *renderPass; SDL_GPUCopyPass *copyPass; uint32_t passCount; // Render passes begun this frame: the first clears the stencil + uint64_t signature; // FNV-1a over the frame's drawing commands as they are recorded + uint64_t signatureBytes; + uint64_t generation; // Geometry, shaders, filters, textures and saved layers made, ever SDL_Rect scissor; Rml::Matrix4f transform; // Projection times the element's transform, as pushed Rml::Matrix4f proj; diff --git a/src/main.c b/src/main.c index 61cd81b99..cf3034f96 100644 --- a/src/main.c +++ b/src/main.c @@ -71,10 +71,13 @@ #include "embedded.h" -#define MENU_OPTIONS "-k -w -v" +#define MENU_OPTIONS "-k -w" #define MENU_BINARY_PREFIX "Singe" // The launcher looks beside itself for this, highest version first #define SUPPORT_VERSION_FILE "version.txt" // Inside the Singe folder: which Singe wrote it #define PRIMARY_DISPLAY 0 +#define GLES_RENDERER "opengles2" // SDL_Renderer driver name; SDL has no constant for it +#define GLES_CONTEXT_MAJOR 2 // What that renderer asks for, so the window is never rebuilt for it +#define GLES_CONTEXT_MINOR 0 #define MIXER_FREQUENCY 44100 #define MIXER_CHANNELS 2 #define MIXER_CHUNK_SAMPLES "1024" // Device buffer, kept small so the audio queue and any error in measuring it stay small @@ -135,13 +138,17 @@ typedef enum LongOptionE { OPT_MONOCHROME, OPT_NOGAMEPAD, OPT_NO_UPDATE_CHECK, + OPT_PERFSYNC, + OPT_QUITCHORD, OPT_REFRESH, + OPT_SCENEQUALITY, OPT_SCREEN, OPT_SOUNDFONT, OPT_STARTSILENT, OPT_TRIGGER_THRESHOLD, OPT_XRATIO, - OPT_YRATIO + OPT_YRATIO, + OPT_ZEROCOPY } LongOptionE; typedef struct OptionS { @@ -223,13 +230,17 @@ static const OptionT _options[] = { { OPT_MONOCHROME, "monochrome", ap_no, NULL, "start with the disc picture in grey", false, true }, { OPT_NOGAMEPAD, "nogamepad", ap_no, NULL, "ignore every gamepad, as --nomouse ignores the mice", false, true }, { OPT_NO_UPDATE_CHECK, "no_update_check", ap_no, NULL, "never look for a newer Singe from the menu", false, true }, + { OPT_PERFSYNC, "perfsync", ap_no, NULL, "with -p, wait for the GPU after each stage of a frame, so the perf line says where the GPU's time goes; slows everything", false, true }, + { OPT_QUITCHORD, "quitchord", ap_yes, "BUTTONS", "first gamepad buttons held together to quit whatever a game maps, such as back+start", false, true }, { OPT_REFRESH, "refresh", ap_no, NULL, "write a fresh Singe folder, keeping the old one as old-Singe-vX.YY", false, false }, + { OPT_SCENEQUALITY, "scenequality", ap_yes, "LEVEL", "3D detail: high (the default), or low for a small GPU: half resolution, no antialiasing, no bloom", false, true }, { OPT_SCREEN, "screen", ap_yes, "N", "open the window on display N, counting from 1", false, true }, { OPT_SOUNDFONT, "soundfont", ap_yes, "FILE", "synthesise MIDI files with this SoundFont (.sf2)", false, true }, { OPT_STARTSILENT, "startsilent", ap_no, NULL, "start muted until the first input of any kind", false, true }, { OPT_TRIGGER_THRESHOLD, "trigger_threshold", ap_yes, "PERCENT", "how far a trigger travels before it counts as a button; 0 uses DEAD_ZONE", false, true }, { OPT_XRATIO, "xratio", ap_yes, "FACTOR", "horizontal gun coordinate scale a game reads with ratioGetX()", false, true }, - { OPT_YRATIO, "yratio", ap_yes, "FACTOR", "vertical gun coordinate scale a game reads with ratioGetY()", false, true } + { OPT_YRATIO, "yratio", ap_yes, "FACTOR", "vertical gun coordinate scale a game reads with ratioGetY()", false, true }, + { OPT_ZEROCOPY, "zerocopy", ap_yes, "BOOLEAN", "hand hardware decoded frames to the GPU without copying them, where it can (the default)", false, true } }; #define OPTION_COUNT (sizeof(_options) / sizeof(_options[0])) @@ -284,6 +295,7 @@ static char *_cloneString(const char *string); #ifndef _WIN32 static void _crashHandler(int signalNumber); #endif +static void _dying(void); static bool _extractFile(const char *filename, const uint8_t *data, size_t length); static char *_findVideoFile(const char *baseName); static void _launcher(const char *exeName, ConfigT *conf); @@ -308,6 +320,8 @@ static void _showUsage(const char *name, const char *message) __attri static void _startSDL(void); static void _stopSDL(void); static void _stripDatabaseNames(char *path); +static void _terminateHandler(int signalNumber); +static void _traceFlushPending(void); static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device); static void _archiveSupport(void); static void _unpackData(const char *exePath, bool absolute, bool refresh); @@ -317,6 +331,9 @@ static void _unpackData(const char *exePath, bool absolute, bool refr // relaunch. main() owns it; everyone else reads it. static char *_executable = NULL; +// SIGTERM or SIGINT arrived: the whole of Singe ends, the menu a game came from included. +static volatile sig_atomic_t _terminated = 0; + const char *mainExecutablePath(void) { return _executable; @@ -384,6 +401,7 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons char *canvasString = NULL; char *sindenString = NULL; char *deinterlaceString = NULL; + char *qualityString = NULL; char *edgeString = NULL; char *temp = NULL; const char *arg = NULL; @@ -702,6 +720,12 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons break; // A Stick For The Mouse + case OPT_ZEROCOPY: + if (!_parseBoolean(arg, &conf->zeroCopy)) { + _optionFail(exeName, source, "--zerocopy takes true or false."); + } + break; + case OPT_JOYMOUSE: if (!_parseBoolean(arg, &conf->joyMouse)) { _optionFail(exeName, source, "--joymouse takes true or false."); @@ -755,11 +779,28 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons break; // A Fresh Support Folder + case OPT_PERFSYNC: + conf->perfSync = true; + break; + + case OPT_QUITCHORD: + if (!singeParseQuitChord(arg, NULL, NULL)) { + _optionFail(exeName, source, "--quitchord takes two to four of SDL's gamepad button names joined by +, such as back+start."); + } + free(conf->quitChord); + conf->quitChord = strdup(arg); + break; + case OPT_REFRESH: conf->refreshSupport = true; break; // Which Display + case OPT_SCENEQUALITY: + free(qualityString); + qualityString = strdup(arg); + break; + case OPT_SCREEN: if (!_parseInteger(arg, &conf->screen) || (conf->screen < SCREEN_MIN)) { _optionFail(exeName, source, "--screen counts the displays from 1."); @@ -867,6 +908,16 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons } free(deinterlaceString); } + if (qualityString) { + if (utilStricmp(qualityString, "high") == 0) { + conf->sceneLow = false; + } else if (utilStricmp(qualityString, "low") == 0) { + conf->sceneLow = true; + } else { + _optionFail(exeName, source, "--scenequality takes high or low."); + } + free(qualityString); + } // Where does the Sinden border go? Without the option a bezel decides: the gun's camera // sees the whole screen, so artwork around the picture pushes the border out to the window. @@ -1014,16 +1065,36 @@ static char *_cloneString(const char *string) { static void _crashHandler(int signalNumber) { void *frames[CRASH_FRAMES_MAX]; int32_t count = backtrace(frames, CRASH_FRAMES_MAX); + int32_t trace = -1; + // The trace gets it too, after any lines still waiting for the header, so trace.txt alone is a + // whole report. + _traceFlushPending(); + trace = utilTraceFd(); fprintf(stderr, "\nSinge crashed (signal %d). Backtrace:\n", signalNumber); backtrace_symbols_fd(frames, count, STDERR_FILENO); - fprintf(stderr, "Run with --program and send trace.txt with this.\n"); + if (trace >= 0) { + dprintf(trace, "Singe crashed (signal %d). Backtrace:\n", signalNumber); + backtrace_symbols_fd(frames, count, trace); + fprintf(stderr, "The backtrace is in trace.txt as well; send that.\n"); + } else { + fprintf(stderr, "Run with --program and send trace.txt with this.\n"); + } signal(signalNumber, SIG_DFL); raise(signalNumber); } #endif +// utilDie's hook, run before it says anything and exits. The trace gets the lines held back for its +// header, and every video decoder thread is stopped: exit() runs the MPP library's teardown, and a +// decoder thread still inside MPP crashed there, burying the message that said what went wrong. +static void _dying(void) { + _traceFlushPending(); + videoStopDecoders(); +} + + // Writes an embedded support file, or rewrites it when the installed copy differs from this build's. static bool _extractFile(const char *filename, const uint8_t *data, size_t length) { FILE *out = NULL; @@ -1101,6 +1172,7 @@ static void _launcher(const char *exeName, ConfigT *conf) { int32_t bestResIndex = -1; float thisRatio = 0.0f; float bestRatio = HUGE_VALF; + double fit = 0.0; SDL_Window *window = NULL; SDL_Renderer *renderer = NULL; SDL_GPUDevice *device = NULL; @@ -1109,6 +1181,8 @@ static void _launcher(const char *exeName, ConfigT *conf) { SDL_DisplayID *displays = NULL; SDL_DisplayID display = 0; const SDL_DisplayMode *mode = NULL; + char *engineData = NULL; + bool gles = false; SDL_AudioSpec spec; _traceHeaderDone = false; @@ -1194,10 +1268,24 @@ static void _launcher(const char *exeName, ConfigT *conf) { if (conf->yResolution <= 0) { _showUsage(exeName, "Unable to determine Y resolution. (Is the X value sane?)"); } + // Larger than the display: shrunk to fit it, keeping its shape, rather than refused. A game's + // games.dat is written on one machine and played on others -- a 1280x960 port on a 640x480 + // handheld ended the menu's launch of it -- and a window cannot be bigger than the screen anyway. if ((conf->xResolution > mode->w) || (conf->yResolution > mode->h)) { - _showUsage(exeName, "Specified resolution is larger than the display."); + fit = SDL_min((double)mode->w / (double)conf->xResolution, (double)mode->h / (double)conf->yResolution); + _mainTrace(conf, "Resolution %dx%d is larger than the display; using %dx%d", conf->xResolution, conf->yResolution, (int32_t)(conf->xResolution * fit), (int32_t)(conf->yResolution * fit)); + conf->xResolution = (int32_t)(conf->xResolution * fit); + conf->yResolution = (int32_t)(conf->yResolution * fit); } + // Any window that ends up on OpenGL is an ES one, as the GLES renderer would ask for. KMSDRM makes + // every window a GL one from the start using SDL's default of desktop GL 2.1, and an ES only + // driver (the Mali blobs) has no EGL config for that, so the window itself could not be created. + // Drivers hand back their newest compatible ES context, which is how the scene still gets 3.1. + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, GLES_CONTEXT_MAJOR); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, GLES_CONTEXT_MINOR); + // Create Window _mainTrace(conf, "Creating window"); window = SDL_CreateWindow("SINGE", conf->xResolution, conf->yResolution, 0); @@ -1242,10 +1330,17 @@ static void _launcher(const char *exeName, ConfigT *conf) { device = NULL; } } + // OpenGL ES by name, because SDL's own order tries desktop OpenGL first. The GLES backend refuses + // a desktop context anyway, and on an ES only driver (the Mali blobs) the attempt rebuilds the + // window around an EGL config that does not exist and leaves it unusable for everything after. if (renderer == NULL) { - renderer = SDL_CreateRenderer(window, NULL); + renderer = SDL_CreateRenderer(window, GLES_RENDERER); if (renderer == NULL) { - utilDie("%s", SDL_GetError()); + _mainTrace(conf, "OpenGL ES renderer failed (%s); letting SDL choose", SDL_GetError()); + renderer = SDL_CreateRenderer(window, NULL); + if (renderer == NULL) { + utilDie("%s", SDL_GetError()); + } } } _mainTrace(conf, "Renderer: %s", SDL_GetRendererName(renderer)); @@ -1253,9 +1348,16 @@ static void _launcher(const char *exeName, ConfigT *conf) { // when the platform has it; otherwise OpenGL ES through the context SDL_Renderer just made, // which is the only one a Pi or a Mali handheld offers. Neither leaves 2D untouched and the // scene and the GUI reporting themselves unavailable, as they already did on such a machine. + // The engine's own things live in the Singe folder of the data directory, not beside the games' + // folders: the GLES backend keeps its linked programs there between runs. + engineData = utilCreateString("%s%s%c", conf->dataDirBase, VFS_ENGINE_DIRECTORY, utilGetPathSeparator()); + if (device == NULL) { + gles = renderGlesStart(engineData); + } + free(engineData); if (device != NULL) { renderSelect(RENDER_GPU, &renderGpuBackend, renderer); - } else if (renderGlesStart()) { + } else if (gles) { renderSelect(RENDER_GLES, &renderGlesBackend, renderer); device = rgpuCreateDevice(RGPU_SHADERFORMAT_ESSL, false, NULL); if (device == NULL) { @@ -1284,6 +1386,7 @@ static void _launcher(const char *exeName, ConfigT *conf) { // How video will be decoded is part of the header, so it is settled before the header is // written; everything else in it is known by now. videoSetHardwareDecoding(!conf->softwareVideo); + videoSetZeroCopy(conf->zeroCopy); _traceHeader(conf, renderer, device); // Start our video playback system @@ -1400,6 +1503,7 @@ static ConfigT *_parseArguments(const char *exeName, int32_t argc, char *argv[]) conf->haptic = RUMBLE_LEVEL_MAX; conf->linearScale = true; conf->mapJoysticks = true; + conf->zeroCopy = true; conf->joyMouseRange = JOY_MOUSE_RANGE_DEFAULT; conf->deinterlace = DEINTERLACE_AUTO; conf->deterministicStep = FRAME_TICK_MS; @@ -1722,6 +1826,10 @@ static void _showUsage(const char *name, const char *message) { static void _startSDL(void) { + // Singe takes SIGTERM and SIGINT itself, to end everything rather than the one game SDL's quit + // event would. Its handlers are in place already; this keeps SDL's off them for certain. + SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1"); + // Init SDL if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_GAMEPAD | SDL_INIT_EVENTS)) { utilDie("%s", SDL_GetError()); @@ -1747,10 +1855,10 @@ static void _stopSDL(void) { // Reduces every component that names a packed game to its stem, in place. A game writes to one -// data directory whether it was reached as the container the engine opened ("ActionMax.game", which -// createDataDirFor already reduces) or by a name that passes through it ("ActionMax.game/video.mkv", +// data directory whether it was reached as the container the engine opened ("MyGame.game", which +// createDataDirFor already reduces) or by a name that passes through it ("MyGame.game/video.mkv", // which is how a script names a file inside one). Without this the two disagreed: a packed game's -// video index went to data/ActionMax.game while everything else it wrote went to data/ActionMax, +// video index went to data/MyGame.game while everything else it wrote went to data/MyGame, // and the library appeared to have two of every game. static void _stripDatabaseNames(char *path) { const size_t extension = strlen(VFS_DATABASE_EXTENSION); @@ -1769,6 +1877,30 @@ static void _stripDatabaseNames(char *path) { } +// SIGTERM and SIGINT: noted, for the frame loop to end the script and main() to run nothing after +// it. A second one while that is under way means it is stuck, and ends the process at once. +static void _terminateHandler(int signalNumber) { + if (_terminated) { + signal(signalNumber, SIG_DFL); + raise(signalNumber); + return; + } + _terminated = 1; +} + + +// Writes out the lines main.c held back for the header. Also run when utilDie ends the program, so a +// start that dies before there is a renderer to describe still leaves its trace behind. +static void _traceFlushPending(void) { + _traceHeaderDone = true; + if (_tracePending != NULL) { + utilTrace("%s", _tracePending); + free(_tracePending); + _tracePending = NULL; + } +} + + // What a bug report needs, at the top of trace.txt and nowhere else: the build, the command that // started it, the machine, and what the engine picked to run on. main.c's own trace lines wait // behind it so the block is always first and can be pasted whole. @@ -1798,12 +1930,7 @@ static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDev free(audio); // Everything main.c held back while the header was still being assembled. - _traceHeaderDone = true; - if (_tracePending != NULL) { - utilTrace("%s", _tracePending); - free(_tracePending); - _tracePending = NULL; - } + _traceFlushPending(); } @@ -1915,7 +2042,8 @@ static void _unpackData(const char *exePath, bool absolute, bool refresh) { free(data); free(temp); - // Script to start menu system. The launcher finds the binary beside itself rather than naming + // Script to start menu system. Whatever it is given goes on to Singe ahead of the script, so + // "Menu.sh -p" traces the menu. The launcher finds the binary beside itself rather than naming // it, because the binary carries its version and a named one stops working at the next release; // the game launchers in the wild do the same. With --gamedir the binary is elsewhere, so there // is nothing to search for and its full path is written instead. @@ -1923,7 +2051,7 @@ static void _unpackData(const char *exePath, bool absolute, bool refresh) { // Unix-ish temp = strdup("Menu.sh"); if (absolute) { - data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\n\"%s\" %s %s/menuBackground.mkv %s/Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); + data = utilCreateString("#!/usr/bin/env bash\n\ncd \"$(dirname \"$0\")\"\n\"%s\" %s \"$@\" %s/Menu.singe\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY); } else { // The best binary beside the script, not the first name the shell happens to list: a // folder holding both 3.00 and a beta sorts differently under different locales, and a @@ -1989,15 +2117,15 @@ static void _unpackData(const char *exePath, bool absolute, bool refresh) { " echo \"Cannot find the %s program beside this script.\"\n" " exit 1\n" "fi\n" - "\"${SINGE}\" %s %s/menuBackground.mkv %s/Menu.singe\n", + "\"${SINGE}\" %s \"$@\" %s/Menu.singe\n", MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, - MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); + MENU_OPTIONS, VFS_ENGINE_DIRECTORY); } } else { // Winders temp = strdup("Menu.bat"); if (absolute) { - data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nstart \"\" \"%s\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY); + data = utilCreateString("@echo off\r\ncd /d \"%%~dp0\"\r\nstart \"\" \"%s\" %s %%* %s\\Menu.singe\r\n", exePath, MENU_OPTIONS, VFS_ENGINE_DIRECTORY); } else { // The best binary beside the script, as Menu.sh picks it, and for the same reasons: // a folder holding a release and a beta must start the release, and "dir" hands them @@ -2043,7 +2171,7 @@ static void _unpackData(const char *exePath, bool absolute, bool refresh) { " pause\r\n" " exit /b 1\r\n" ")\r\n" - "start \"\" \"!SINGE!\" %s %s\\menuBackground.mkv %s\\Menu.singe\r\n" + "start \"\" \"!SINGE!\" %s %%* %s\\Menu.singe\r\n" "exit /b 0\r\n" "\r\n" ":key\r\n" @@ -2078,7 +2206,7 @@ static void _unpackData(const char *exePath, bool absolute, bool refresh) { "set \"bet=000!bet!\"\r\n" "set \"KEY=!MINE!1!maj:~-2!!min:~-2!!rel!!bet:~-3!\"\r\n" "exit /b\r\n", - MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, VFS_ENGINE_DIRECTORY, MENU_BINARY_PREFIX); + MENU_BINARY_PREFIX, MENU_BINARY_PREFIX, MENU_OPTIONS, VFS_ENGINE_DIRECTORY, MENU_BINARY_PREFIX); } } created |= _extractFile(temp, (const uint8_t *)data, strlen(data)); @@ -2112,6 +2240,7 @@ ConfigT *cloneConf(const ConfigT *conf) { c->deinterlace = conf->deinterlace; c->audioSuffix = _cloneString(conf->audioSuffix); c->gamepadOrder = _cloneString(conf->gamepadOrder); + c->quitChord = _cloneString(conf->quitChord); c->videoFile = _cloneString(conf->videoFile); c->gameId = _cloneString(conf->gameId); c->dataDirBase = _cloneString(conf->dataDirBase); @@ -2199,6 +2328,7 @@ void destroyConf(ConfigT **confPointer) { free(conf->soundfont); free(conf->audioSuffix); free(conf->gamepadOrder); + free(conf->quitChord); free(conf); *confPointer = NULL; } @@ -2301,6 +2431,12 @@ char *mainDescribeOs(void) { } +// Whether SIGTERM or SIGINT has asked the whole of Singe to end. +bool mainTerminated(void) { + return _terminated != 0; +} + + // Ok, this thing can have a mess of different arguments: // WW - Just the width of the white border // WW WB - Width of white border and then black border @@ -2381,6 +2517,8 @@ int main(int argc, char *argv[]) { signal(SIGABRT, _crashHandler); signal(SIGFPE, _crashHandler); #endif + signal(SIGTERM, _terminateHandler); + signal(SIGINT, _terminateHandler); // Kept whole for the trace header, because a bug report that does not say how Singe was // started says very little. @@ -2448,9 +2586,11 @@ int main(int argc, char *argv[]) { // Queue initial script _resolveFiles(exeName, conf); queueScript(conf); + singeGamepadHint(conf); destroyConf(&conf); _startSDL(); + utilSetDieHook(_dying); // Run script queue while (_scriptQueue) { @@ -2470,6 +2610,14 @@ int main(int argc, char *argv[]) { free(q); utilTraceEnd(); + + // Terminated: nothing queued runs, not even the menu the game came from. + while (_terminated && _scriptQueue) { + q = _scriptQueue; + destroyConf(&q->conf); + LL_DELETE(_scriptQueue, q); + free(q); + } } _stopSDL(); @@ -2482,7 +2630,7 @@ int main(int argc, char *argv[]) { // A script asked for another executable to take over -- the menu's Engine Update, having put // a newer Singe beside this one. Everything above has been torn down, so what starts now // starts as clean as a fresh launch, with the same arguments. - if (singeRelaunchPath() != NULL) { + if ((singeRelaunchPath() != NULL) && !_terminated) { relaunched = _relaunch(singeRelaunchPath(), argc, argv); } free(_executable); diff --git a/src/main.h b/src/main.h index c844e370d..ace014381 100644 --- a/src/main.h +++ b/src/main.h @@ -66,6 +66,7 @@ char *mainDescribeAudioDecoders(void); char *mainDescribeCpu(void); char *mainDescribeOs(void); const char *mainExecutablePath(void); +bool mainTerminated(void); bool parseSindenString(const char *sindenString, ConfigT *conf); void queueScript(const ConfigT *conf); char *resolveDataDir(const ConfigT *conf); diff --git a/src/render.c b/src/render.c index ff3d0d116..ae0bc1082 100644 --- a/src/render.c +++ b/src/render.c @@ -29,6 +29,9 @@ #include "util.h" +#define DMABUF_TARGET_PROPERTY "singe.dmabufTarget" // On a renderDmabufTarget texture: the backend texture behind it + + static const RenderBackendT *_backend = NULL; static SDL_Renderer *_renderer = NULL; static RenderApiT _api = RENDER_NONE; @@ -59,6 +62,77 @@ void renderBegin(void) { } +bool renderDmabufAvailable(void) { + return (_api == RENDER_GLES) && renderGlesDmabufAvailable(); +} + + +// Converts a dma-buf frame into a texture renderDmabufTarget made. The GL work happens inside the +// usual handover, since SDL_Renderer shares the context and caches what it thinks is bound. +bool renderDmabufConvert(const RenderDmabufT *frame, SDL_Texture *target) { + SDL_GPUTexture *texture = (target != NULL) ? (SDL_GPUTexture *)SDL_GetPointerProperty(SDL_GetTextureProperties(target), DMABUF_TARGET_PROPERTY, NULL) : NULL; + bool ok = false; + + if ((texture == NULL) || !renderDmabufAvailable()) { + return false; + } + renderBegin(); + ok = renderGlesDmabufConvert(frame, texture); + renderEnd(); + + return ok; +} + + +// The DRM device the picture is drawn on, for a hardware decoder to open so its frames can be +// imported; NULL wherever zero-copy is not in use. +const char *renderDmabufNode(void) { + return renderDmabufAvailable() ? renderGlesDmabufNode() : NULL; +} + + +// An RGBA texture SDL_Renderer can draw, which dma-buf frames are converted into. The backend's +// texture behind it is kept on it as a property, because SDL does not own a texture it was handed. +SDL_Texture *renderDmabufTarget(SDL_Renderer *renderer, int32_t width, int32_t height) { + SDL_GPUTexture *texture = NULL; + SDL_Texture *result = NULL; + + if (!renderDmabufAvailable()) { + return NULL; + } + renderBegin(); + texture = renderGlesDmabufTarget(width, height); + renderEnd(); + if (texture == NULL) { + return NULL; + } + result = renderWrapTexture(renderer, texture, SDL_PIXELFORMAT_RGBA32, width, height); + if (result == NULL) { + renderBegin(); + renderGlesDmabufTargetFree(texture); + renderEnd(); + return NULL; + } + SDL_SetPointerProperty(SDL_GetTextureProperties(result), DMABUF_TARGET_PROPERTY, texture); + + return result; +} + + +void renderDmabufTargetDestroy(SDL_Texture *target) { + SDL_GPUTexture *texture = NULL; + + if (target == NULL) { + return; + } + texture = (SDL_GPUTexture *)SDL_GetPointerProperty(SDL_GetTextureProperties(target), DMABUF_TARGET_PROPERTY, NULL); + SDL_DestroyTexture(target); + renderBegin(); + renderGlesDmabufTargetFree(texture); + renderEnd(); +} + + // Nothing to undo under SDL_GPU; it owns no state SDL_Renderer can see. GLES shares one context, // so it puts back the bindings SDL does not track for itself. void renderEnd(void) { @@ -68,6 +142,21 @@ void renderEnd(void) { } +// Submits what SDL_Renderer has queued and waits for the GPU to finish all of it: --perfsync puts +// the GPU's time for each stage of a frame into that stage, where -p can see it. Slow, and only +// for measuring. +void renderFinish(SDL_GPUDevice *device) { + if (_renderer != NULL) { + SDL_FlushRenderer(_renderer); + } + if (_api == RENDER_GLES) { + renderGlesFinish(); + } else if ((_api == RENDER_GPU) && (device != NULL)) { + SDL_WaitForGPUIdle(device); + } +} + + void renderSelect(RenderApiT api, const RenderBackendT *backend, SDL_Renderer *renderer) { _api = backend != NULL ? api : RENDER_NONE; _backend = backend; diff --git a/src/render.h b/src/render.h index fb49c33fb..7be443280 100644 --- a/src/render.h +++ b/src/render.h @@ -129,6 +129,7 @@ RenderApiT renderApi(void); const char *renderApiName(void); void renderBegin(void); void renderEnd(void); +void renderFinish(SDL_GPUDevice *device); void renderSelect(RenderApiT api, const RenderBackendT *backend, SDL_Renderer *renderer); // The composite seam. The scene and the GUI do not draw to the swapchain; each renders into its @@ -139,10 +140,49 @@ void renderSelect(RenderApiT api, const RenderBackendT *backend, SDL_Ren SDL_Texture *renderWrapTexture(SDL_Renderer *renderer, SDL_GPUTexture *texture, SDL_PixelFormat format, int32_t width, int32_t height); SDL_GPUTexture *renderTextureFor(SDL_Texture *texture); +// Zero-copy video. A hardware decoder's picture as a Linux dma-buf -- the DRM PRIME frames Rockchip's +// MPP hands back, and VAAPI's once mapped -- turned into an RGBA texture by the GPU, the CPU never +// touching a pixel: no read back on the decoder thread, no upload on this one. GLES only, and only +// where EGL imports dma-bufs and GLES samples them as external textures; renderDmabufAvailable says +// whether that is so, and on every other machine the rest simply answer no. +#define RENDER_DMABUF_PLANES 4 + +typedef enum RenderColorSpaceE { + RENDER_COLORSPACE_BT601 = 0, + RENDER_COLORSPACE_BT709, + RENDER_COLORSPACE_BT2020 +} RenderColorSpaceE; + +typedef struct RenderDmabufS { + int32_t width; + int32_t height; + uint32_t fourcc; // DRM_FORMAT_*: NV12 is what the decoders give + int32_t planes; + int32_t fd[RENDER_DMABUF_PLANES]; + uint32_t offset[RENDER_DMABUF_PLANES]; + uint32_t pitch[RENDER_DMABUF_PLANES]; + uint64_t modifier; // The tiling, or DRM_FORMAT_MOD_INVALID for none said + RenderColorSpaceE colorSpace; + bool fullRange; +} RenderDmabufT; + +bool renderDmabufAvailable(void); +bool renderDmabufConvert(const RenderDmabufT *frame, SDL_Texture *target); +const char *renderDmabufNode(void); +SDL_Texture *renderDmabufTarget(SDL_Renderer *renderer, int32_t width, int32_t height); +void renderDmabufTargetDestroy(SDL_Texture *target); + // The GLES backend's own two. renderGlesStart looks at the context SDL_Renderer already made and -// says whether it is one this backend can use; renderGlesRestore puts that context back as found. +// says whether it is one this backend can use, keeping linked programs in cacheDirectory between +// runs (NULL for nowhere); renderGlesRestore puts that context back as found. +bool renderGlesDmabufAvailable(void); +bool renderGlesDmabufConvert(const RenderDmabufT *frame, SDL_GPUTexture *target); +const char *renderGlesDmabufNode(void); +SDL_GPUTexture *renderGlesDmabufTarget(int32_t width, int32_t height); +void renderGlesDmabufTargetFree(SDL_GPUTexture *target); +void renderGlesFinish(void); void renderGlesRestore(void); -bool renderGlesStart(void); +bool renderGlesStart(const char *cacheDirectory); Uint32 renderGlesTextureName(SDL_GPUTexture *texture); SDL_GPUTexture *renderGlesTextureWrap(Uint32 name, int32_t width, int32_t height); diff --git a/src/renderGles.c b/src/renderGles.c index 9a776947b..fd2a04023 100644 --- a/src/renderGles.c +++ b/src/renderGles.c @@ -45,6 +45,7 @@ #include #include #include +#include #include "renderGlesApi.h" #include "render.h" #include "util.h" @@ -58,6 +59,33 @@ #define GLES_FRAMEBUFFER_CACHE 32 #define GLES_LOG_BYTES 1024 // A driver's compile or link log, which is the only clue it gives #define GLES_NAME_BYTES 128 // A uniform block's name as SPIRV-Cross emits it +#define GLES_VERTEX_BUFFER_UNIT GLES_MAX_SAMPLERS // First texture unit of the vertex stage's buffer textures, clear of the fragment samplers +#define GLES_TEXEL_BYTES 16 // One RGBA32F texel: a buffer texture reads a float4 at a time +#define GLES_BINDING_DIGITS 12 // Room for one relocated "binding = N" number +#define GLES_EXT_TEXTURE_BUFFER "GL_EXT_texture_buffer" +#define GLES_OES_TEXTURE_BUFFER "GL_OES_texture_buffer" +#define GLES_VERSION_310 "#version 310 es" +#define GLES_SAMPLER_BUFFER "samplerBuffer" +#define GLES_PROGRAM_CACHE 64 // Distinct shader pairs linked at once; the scene and the GUI use about twenty +#define GLES_BINARY_MAGIC "SINGEGLP" +#define GLES_BINARY_MAGIC_BYTES 8 +#define GLES_BINARY_NAME "glesProgram-%016" PRIx64 ".bin" +#define GLES_BINARY_TEMPORARY ".tmp" +#define GLES_BINARY_DIRECTORY_MODE 0777 +#define GLES_FNV_OFFSET 14695981039346656037ull // 64 bit FNV-1a, naming a cached program +#define GLES_FNV_PRIME 1099511628211ull +#define GLES_DMABUF_ATTRIBUTES 64 // Room for every eglCreateImageKHR attribute a four plane frame can need +#define GLES_DMABUF_QUAD_VERTICES 4 // The conversion pass draws one triangle strip over the target +#define GLES_DRM_FORMAT_MOD_INVALID 0x00ffffffffffffffull +#define GLES_DRM_NV12 0x3231564eu // DRM fourccs: 'NV12', and the two planes of it apart +#define GLES_DRM_R8 0x20203852u // 'R8 ' +#define GLES_DRM_GR88 0x38385247u // 'GR88' +#define GLES_YUV_ROWS 3 // The colour matrix, row by row, for the planar conversion +#define GLES_LIMITED_BLACK (16.0f / 255.0f) +#define GLES_LIMITED_LUMA_SCALE (255.0f / 219.0f) +#define GLES_LIMITED_CHROMA_SCALE (255.0f / 224.0f) +#define GLES_CHROMA_ZERO (128.0f / 255.0f) +#define GLES_VERSION_NUMBER_320 "320" #define GLES_BLOCK_SIDE 4 // Pixels along a block's side, ETC2 and ASTC 4x4 alike #define GLES_BLOCK_BYTES 16 // Bytes a block takes in either @@ -77,9 +105,20 @@ typedef struct GlesTextureS { bool borrowed; // SDL owns this name; releasing the wrapper must not delete it } GlesTextureT; +// How this driver offers texture buffers to a "#version 310 es" shader, which the scene's vertex +// shaders need in place of storage blocks (see _glesBindVertexStorageBuffers). +typedef enum GlesTextureBufferE { + GLES_TEXTURE_BUFFER_NONE = 0, // Not at all: the scene's pipelines fail to link and 3D is off + GLES_TEXTURE_BUFFER_EXT, // GL_EXT_texture_buffer, which is what SPIRV-Cross asks for + GLES_TEXTURE_BUFFER_OES, // GL_OES_texture_buffer only: the shaders' #extension is renamed + GLES_TEXTURE_BUFFER_CORE // ES 3.2 without either name listed: the shaders become 3.20 +} GlesTextureBufferE; + typedef struct GlesBufferS { - GLuint name; - GLenum target; + GLuint name; + GLenum target; + GLuint texture; // An RGBA32F buffer texture over it when it is a storage buffer, else 0 + uint32_t size; // Bytes, kept so an upload can orphan the storage it replaces } GlesBufferT; // A transfer buffer is plain memory. GLES has no persistent mapping worth relying on here, and the @@ -94,14 +133,33 @@ typedef struct GlesTransferS { // kept: the ES shaders carry their own layout bindings, so the counts would be a second, weaker // description of the same thing. typedef struct GlesShaderS { - GLuint name; - GLenum stage; - char *source; + GLuint name; + GLenum stage; + char *source; + uint64_t serial; // Never reused, unlike a GL name or an address, so it can key a program } GlesShaderT; +// A linked program, shared by every pipeline over the same two shaders. GLES has no pipeline +// object: blending, culling, depth and sample count are set when a pipeline is bound, so the scene's +// variants of one shader pair differ in nothing a link produces -- and a Mali link takes up to a +// second. Linked once per pair, and once per driver ever when the binary cache is available. +typedef struct GlesProgramS { + GLuint name; // 0 when the slot is free + uint64_t vertexSerial; + uint64_t fragmentSerial; + GLint baseInstance; // SPIRV_Cross_BaseInstance, or -1 when the shader has none + uint32_t references; +} GlesProgramT; + +// The header in front of a cached program binary. The driver's own format number travels with it. +typedef struct GlesBinaryHeaderS { + char magic[GLES_BINARY_MAGIC_BYTES]; + uint32_t format; + uint32_t length; +} GlesBinaryHeaderT; + typedef struct GlesPipelineS { - GLuint program; - GLint baseInstance; // SPIRV_Cross_BaseInstance, or -1 when the shader has none + GlesProgramT *program; GLuint vertexArray; SDL_GPUVertexAttribute attributes[GLES_MAX_ATTRIBUTES]; uint32_t attributeCount; @@ -133,6 +191,9 @@ typedef struct GlesRenderPassS { SDL_GPUColorTargetInfo colour[GLES_MAX_COLOR_TARGETS]; // Kept so the pass can honour store_op when it ends uint32_t colourCount; GLuint framebuffer; + bool hasDepth; + SDL_GPUStoreOp depthStore; + SDL_GPUStoreOp stencilStore; GLenum indexType; uint32_t width; uint32_t height; @@ -143,6 +204,32 @@ typedef struct GlesCommandBufferS { bool issued; // Read when a buffer is cancelled, to be sure nothing had been drawn } GlesCommandBufferT; +// Zero-copy video: what is needed to import a dma-buf and draw it into an RGBA texture. Looked for +// the first time a video asks, since the EGL display is only certain once the context is. +typedef struct GlesDmabufS { + bool checked; + bool available; + bool modifiers; // EGL_EXT_image_dma_buf_import_modifiers: tiled frames can be described + bool planar; // The driver refused NV12 as one image, so its planes go in apart + SDL_EGLDisplay display; + PFNEGLCREATEIMAGEKHRPROC createImage; + PFNEGLDESTROYIMAGEKHRPROC destroyImage; + EGLint (EGLAPIENTRY *getError)(void); + GLuint external; // Whole NV12 through the driver's own conversion: program, sampler, texture + GLint externalPicture; + GLuint externalTexture; + GLuint planes; // The planes apart, converted here: program, samplers, matrix, textures + GLint planesLuma; + GLint planesChroma; + GLint planesRows; + GLint planesOffset; + GLuint planeTextures[2]; + GLuint quad; // Vertex buffer of the strip both draw + GLuint vertexArray; // Its own, so the attribute it enables never touches SDL_Renderer's + GLuint framebuffer; + char *node; // The DRM node the display draws on, where a decoder's frames can be imported +} GlesDmabufT; + // The device. Cast to and from SDL_GPUDevice *, which is opaque, so the engine never learns the // difference. typedef struct GlesDeviceS { @@ -156,6 +243,13 @@ typedef struct GlesDeviceS { int32_t uniformAlignment; GLfloat maxAnisotropy; // 0 when the driver has no GL_EXT_texture_filter_anisotropic bool astc; // GL_KHR_texture_compression_astc_ldr: ASTC 4x4 textures + GlesTextureBufferE textureBuffers; + GLint maxTextureBufferTexels; + GlesProgramT programs[GLES_PROGRAM_CACHE]; + uint64_t shaderSerial; + char *binaryDirectory; // Where linked programs are kept between runs, NULL for nowhere + GlesDmabufT dmabuf; + bool binaries; // The driver hands out program binaries at all bool uniformWrapped; bool ready; } GlesDeviceT; @@ -168,6 +262,11 @@ static void _bindTextureUnit(uint32_t unit, GlesTextureT *te static GLenum _blendFactor(SDL_GPUBlendFactor factor); static GLenum _blendOp(SDL_GPUBlendOp op); static GLenum _compare(SDL_GPUCompareOp op); +static void _dmabufDraw(GLuint program, const GlesTextureT *out); +static EGLImageKHR _dmabufImage(const RenderDmabufT *frame, uint32_t fourcc, int32_t width, int32_t height, int32_t first, int32_t count); +static void _dmabufNode(void); +static GLuint _dmabufProgram(const char *fragmentSource); +static void _dmabufStart(void); static uint32_t _compressedBytes(SDL_GPUTextureFormat format, uint32_t width, uint32_t height); static void _formatToGl(SDL_GPUTextureFormat format, GLenum *internalFormat, GLenum *layout, GLenum *type); static GLuint _framebufferFor(const SDL_GPUColorTargetInfo *colour, uint32_t colourCount, const SDL_GPUDepthStencilTargetInfo *depth, uint32_t *width, uint32_t *height); @@ -217,15 +316,28 @@ static bool _glesTextureSupportsSampleCount(SDL_GPUDevice *d static void _glesUnmapTransferBuffer(SDL_GPUDevice *device, SDL_GPUTransferBuffer *transferBuffer); static void _glesUploadToBuffer(SDL_GPUCopyPass *copyPass, const SDL_GPUTransferBufferLocation *source, const SDL_GPUBufferRegion *destination, bool cycle); static void _glesUploadToTexture(SDL_GPUCopyPass *copyPass, const SDL_GPUTextureTransferInfo *source, const SDL_GPUTextureRegion *destination, bool cycle); +static void _invalidate(GLuint framebuffer, const bool *colour, uint32_t colourCount, bool depth, bool stencil); +static char * _prepareSource(const char *source); static GLenum _primitive(SDL_GPUPrimitiveType type); +static GlesProgramT * _programFor(GlesShaderT *vertex, GlesShaderT *fragment); +static uint64_t _programKey(const GlesShaderT *vertex, const GlesShaderT *fragment); +static bool _programLoad(GLuint program, uint64_t key); +static void _programRelease(GlesProgramT *program); +static void _programSave(GLuint program, uint64_t key); static void _remapFragmentBlocks(GLuint program, const char *fragmentSource); static GLuint _scratchFramebuffer(GLenum binding, GlesTextureT *texture, uint32_t level); static void _setPipelineState(GlesPipelineT *pipeline); static GLenum _stencilOp(SDL_GPUStencilOp op); +static void _trimLog(char *log); static void _vertexFormat(SDL_GPUVertexElementFormat format, GLint *size, GLenum *type, GLboolean *normalized); +bool renderGlesDmabufAvailable(void); +bool renderGlesDmabufConvert(const RenderDmabufT *frame, SDL_GPUTexture *target); +const char *renderGlesDmabufNode(void); +SDL_GPUTexture *renderGlesDmabufTarget(int32_t width, int32_t height); +void renderGlesDmabufTargetFree(SDL_GPUTexture *target); void renderGlesRestore(void); -bool renderGlesStart(void); +bool renderGlesStart(const char *cacheDirectory); static GLenum _addressMode(SDL_GPUSamplerAddressMode mode) { @@ -349,6 +461,198 @@ static uint32_t _compressedBytes(SDL_GPUTextureFormat format, uint32_t width, ui } +// Draws the strip over out with program, whose samplers are already bound. State SDL_Renderer keeps is +// put back by the handover around this, not here. +static void _dmabufDraw(GLuint program, const GlesTextureT *out) { + glBindFramebuffer(GL_FRAMEBUFFER, _device.dmabuf.framebuffer); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, out->name, 0); + glViewport(0, 0, (GLsizei)out->width, (GLsizei)out->height); + glDisable(GL_BLEND); + glDisable(GL_DEPTH_TEST); + glDisable(GL_STENCIL_TEST); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_CULL_FACE); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glUseProgram(program); + glBindVertexArray(_device.dmabuf.vertexArray); + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, GLES_DMABUF_QUAD_VERTICES, 1); + glBindVertexArray(0); +} + + +// An EGL image over count of the frame's planes from first, described as fourcc at width x height: +// the whole frame as NV12, or one plane of it as R8 or GR88. +static EGLImageKHR _dmabufImage(const RenderDmabufT *frame, uint32_t fourcc, int32_t width, int32_t height, int32_t first, int32_t count) { + static const EGLint planeFd[RENDER_DMABUF_PLANES] = { EGL_DMA_BUF_PLANE0_FD_EXT, EGL_DMA_BUF_PLANE1_FD_EXT, EGL_DMA_BUF_PLANE2_FD_EXT, EGL_DMA_BUF_PLANE3_FD_EXT }; + static const EGLint planeOffset[RENDER_DMABUF_PLANES] = { EGL_DMA_BUF_PLANE0_OFFSET_EXT, EGL_DMA_BUF_PLANE1_OFFSET_EXT, EGL_DMA_BUF_PLANE2_OFFSET_EXT, EGL_DMA_BUF_PLANE3_OFFSET_EXT }; + static const EGLint planePitch[RENDER_DMABUF_PLANES] = { EGL_DMA_BUF_PLANE0_PITCH_EXT, EGL_DMA_BUF_PLANE1_PITCH_EXT, EGL_DMA_BUF_PLANE2_PITCH_EXT, EGL_DMA_BUF_PLANE3_PITCH_EXT }; + static const EGLint planeLow[RENDER_DMABUF_PLANES] = { EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT }; + static const EGLint planeHigh[RENDER_DMABUF_PLANES] = { EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT }; + EGLint attribs[GLES_DMABUF_ATTRIBUTES]; + int32_t used = 0; + int32_t plane = 0; + + attribs[used++] = EGL_WIDTH; + attribs[used++] = width; + attribs[used++] = EGL_HEIGHT; + attribs[used++] = height; + attribs[used++] = EGL_LINUX_DRM_FOURCC_EXT; + attribs[used++] = (EGLint)fourcc; + for (plane = 0; plane < count; plane++) { + attribs[used++] = planeFd[plane]; + attribs[used++] = frame->fd[first + plane]; + attribs[used++] = planeOffset[plane]; + attribs[used++] = (EGLint)frame->offset[first + plane]; + attribs[used++] = planePitch[plane]; + attribs[used++] = (EGLint)frame->pitch[first + plane]; + if (_device.dmabuf.modifiers && (frame->modifier != GLES_DRM_FORMAT_MOD_INVALID)) { + attribs[used++] = planeLow[plane]; + attribs[used++] = (EGLint)(frame->modifier & 0xffffffffu); + attribs[used++] = planeHigh[plane]; + attribs[used++] = (EGLint)(frame->modifier >> 32); + } + } + if (fourcc == GLES_DRM_NV12) { + attribs[used++] = EGL_YUV_COLOR_SPACE_HINT_EXT; + attribs[used++] = (frame->colorSpace == RENDER_COLORSPACE_BT709) ? EGL_ITU_REC709_EXT : ((frame->colorSpace == RENDER_COLORSPACE_BT2020) ? EGL_ITU_REC2020_EXT : EGL_ITU_REC601_EXT); + attribs[used++] = EGL_SAMPLE_RANGE_HINT_EXT; + attribs[used++] = frame->fullRange ? EGL_YUV_FULL_RANGE_EXT : EGL_YUV_NARROW_RANGE_EXT; + } + attribs[used++] = EGL_NONE; + + return _device.dmabuf.createImage(_device.dmabuf.display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, NULL, attribs); +} + + +// Which DRM device the display draws on: its render node, or failing that its card node. A machine +// with two GPUs decodes on whichever a decoder opens first, and a dma-buf tiled for one GPU cannot be +// imported by the other, so the decoder is pointed at this one. NULL when EGL cannot say. +static void _dmabufNode(void) { + PFNEGLQUERYDISPLAYATTRIBEXTPROC queryDisplay = (PFNEGLQUERYDISPLAYATTRIBEXTPROC)SDL_EGL_GetProcAddress("eglQueryDisplayAttribEXT"); + PFNEGLQUERYDEVICESTRINGEXTPROC queryDevice = (PFNEGLQUERYDEVICESTRINGEXTPROC)SDL_EGL_GetProcAddress("eglQueryDeviceStringEXT"); + EGLAttrib device = 0; + const char *node = NULL; + + if ((queryDisplay == NULL) || (queryDevice == NULL) || !queryDisplay(_device.dmabuf.display, EGL_DEVICE_EXT, &device) || (device == 0)) { + return; + } + node = queryDevice((EGLDeviceEXT)device, EGL_DRM_RENDER_NODE_FILE_EXT); + if (node == NULL) { + node = queryDevice((EGLDeviceEXT)device, EGL_DRM_DEVICE_FILE_EXT); + } + // A failed query leaves an error behind that is not anybody's to report. + if (_device.dmabuf.getError != NULL) { + _device.dmabuf.getError(); + } + if (node != NULL) { + _device.dmabuf.node = strdup(node); + utilTrace("Gles: drawing on %s", node); + } +} + + +// One of the two conversion programs: the strip's vertex shader with fragmentSource. 0 when it will not link. +static GLuint _dmabufProgram(const char *fragmentSource) { + static const char *vertexSource = "attribute vec2 position;\nvarying vec2 uv;\nvoid main() {\n\tuv = position * 0.5 + 0.5;\n\tgl_Position = vec4(position, 0.0, 1.0);\n}\n"; + GLuint vertex = glCreateShader(GL_VERTEX_SHADER); + GLuint fragment = glCreateShader(GL_FRAGMENT_SHADER); + GLuint program = glCreateProgram(); + GLint ok = 0; + + glShaderSource(vertex, 1, &vertexSource, NULL); + glShaderSource(fragment, 1, &fragmentSource, NULL); + glCompileShader(vertex); + glCompileShader(fragment); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glBindAttribLocation(program, 0, "position"); + glLinkProgram(program); + glDeleteShader(vertex); + glDeleteShader(fragment); + glGetProgramiv(program, GL_LINK_STATUS, &ok); + if (!ok) { + char log[GLES_LOG_BYTES] = { 0 }; + + glGetProgramInfoLog(program, sizeof(log) - 1, NULL, log); + _trimLog(log); + utilTrace("Gles: a zero-copy program would not link: %s", log); + glDeleteProgram(program); + return 0; + } + + return program; +} + + +// Whether this driver can take a decoder's dma-buf and sample it. Two ways, since drivers differ: +// the whole NV12 frame as one external texture, which the driver converts itself -- and, where it +// refuses that (AMD's tiled frames do), the two planes apart as ordinary R8 and GR88 textures with the +// conversion done here. Anything missing, and video frames are copied as they always were. +static void _dmabufStart(void) { + static const char *externalSource = "#extension GL_OES_EGL_image_external : require\nprecision mediump float;\nuniform samplerExternalOES picture;\nvarying vec2 uv;\nvoid main() {\n\tgl_FragColor = vec4(texture2D(picture, uv).rgb, 1.0);\n}\n"; + static const char *planesSource = "precision mediump float;\nuniform sampler2D luma;\nuniform sampler2D chroma;\nuniform vec3 rows[3];\nuniform vec3 offset;\nvarying vec2 uv;\nvoid main() {\n\tvec3 yuv = vec3(texture2D(luma, uv).r, texture2D(chroma, uv).rg) - offset;\n\tgl_FragColor = vec4(dot(rows[0], yuv), dot(rows[1], yuv), dot(rows[2], yuv), 1.0);\n}\n"; + static const GLfloat quad[GLES_DMABUF_QUAD_VERTICES * 2] = { -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f }; + const char *(EGLAPIENTRY *queryString)(SDL_EGLDisplay display, EGLint name) = NULL; + const char *eglExtensions = NULL; + const char *glExtensions = (const char *)glGetString(GL_EXTENSIONS); + const char *missing = NULL; + + _device.dmabuf.checked = true; + _device.dmabuf.display = SDL_EGL_GetCurrentDisplay(); + queryString = (const char *(EGLAPIENTRY *)(SDL_EGLDisplay, EGLint))SDL_EGL_GetProcAddress("eglQueryString"); + if ((_device.dmabuf.display != NULL) && (queryString != NULL)) { + eglExtensions = queryString(_device.dmabuf.display, EGL_EXTENSIONS); + } + _device.dmabuf.createImage = (PFNEGLCREATEIMAGEKHRPROC)SDL_EGL_GetProcAddress("eglCreateImageKHR"); + _device.dmabuf.destroyImage = (PFNEGLDESTROYIMAGEKHRPROC)SDL_EGL_GetProcAddress("eglDestroyImageKHR"); + _device.dmabuf.getError = (EGLint (EGLAPIENTRY *)(void))SDL_EGL_GetProcAddress("eglGetError"); + if (eglExtensions == NULL) { + missing = "an EGL display"; + } else if (strstr(eglExtensions, "EGL_EXT_image_dma_buf_import") == NULL) { + missing = "EGL_EXT_image_dma_buf_import"; + } else if ((_device.dmabuf.createImage == NULL) || (_device.dmabuf.destroyImage == NULL) || (glEGLImageTargetTexture2DOES == NULL)) { + missing = "EGL image entry points"; + } + if (missing != NULL) { + utilTrace("Gles: no zero-copy video (no %s); decoded frames are copied", missing); + return; + } + _device.dmabuf.modifiers = strstr(eglExtensions, "EGL_EXT_image_dma_buf_import_modifiers") != NULL; + _dmabufNode(); + // The external way needs GL_OES_EGL_image_external; without it the planes go in apart from the start. + if ((glExtensions != NULL) && (strstr(glExtensions, "GL_OES_EGL_image_external") != NULL)) { + _device.dmabuf.external = _dmabufProgram(externalSource); + _device.dmabuf.externalPicture = glGetUniformLocation(_device.dmabuf.external, "picture"); + glGenTextures(1, &_device.dmabuf.externalTexture); + } + _device.dmabuf.planar = _device.dmabuf.external == 0; + _device.dmabuf.planes = _dmabufProgram(planesSource); + _device.dmabuf.planesLuma = glGetUniformLocation(_device.dmabuf.planes, "luma"); + _device.dmabuf.planesChroma = glGetUniformLocation(_device.dmabuf.planes, "chroma"); + _device.dmabuf.planesRows = glGetUniformLocation(_device.dmabuf.planes, "rows"); + _device.dmabuf.planesOffset = glGetUniformLocation(_device.dmabuf.planes, "offset"); + if (_device.dmabuf.planes == 0) { + utilTrace("Gles: no zero-copy video (its program would not link); decoded frames are copied"); + return; + } + glGenTextures(2, _device.dmabuf.planeTextures); + // The strip's attribute lives in a vertex array of its own: enabling it on the default one, which + // SDL_Renderer draws with, left SDL drawing nothing at all. + glGenBuffers(1, &_device.dmabuf.quad); + glGenVertexArrays(1, &_device.dmabuf.vertexArray); + glBindVertexArray(_device.dmabuf.vertexArray); + glBindBuffer(GL_ARRAY_BUFFER, _device.dmabuf.quad); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, NULL); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glGenFramebuffers(1, &_device.dmabuf.framebuffer); + _device.dmabuf.available = true; + utilTrace("Gles: zero-copy video through dma-buf import%s", _device.dmabuf.modifiers ? ", tiled frames included" : ""); +} + + // The formats the engine actually asks for. Anything else reports zero, and rgpuTextureSupportsFormat // turns that into a plain no so the caller falls back the way it already does on a weak device. // The block formats: ETC2 is every ES 3 part's, ASTC 4x4 the extension's. @@ -566,6 +870,7 @@ static SDL_GPURenderPass *_glesBeginRenderPass(SDL_GPUCommandBuffer *commandBuff GlesCommandBufferT *commands = (GlesCommandBufferT *)commandBuffer; GlesRenderPassT *pass = &commands->renderPass; GLbitfield clear = 0; + bool discard[GLES_MAX_COLOR_TARGETS]; uint32_t i; pass->framebuffer = _framebufferFor(colorTargetInfos, numColorTargets, depthStencilTargetInfo, &pass->width, &pass->height); @@ -576,7 +881,13 @@ static SDL_GPURenderPass *_glesBeginRenderPass(SDL_GPUCommandBuffer *commandBuff pass->colourCount = numColorTargets < GLES_MAX_COLOR_TARGETS ? numColorTargets : GLES_MAX_COLOR_TARGETS; for (i = 0; i < pass->colourCount; i++) { pass->colour[i] = colorTargetInfos[i]; + discard[i] = colorTargetInfos[i].load_op == SDL_GPU_LOADOP_DONT_CARE; } + pass->hasDepth = depthStencilTargetInfo != NULL; + pass->depthStore = pass->hasDepth ? depthStencilTargetInfo->store_op : SDL_GPU_STOREOP_STORE; + pass->stencilStore = pass->hasDepth ? depthStencilTargetInfo->stencil_store_op : SDL_GPU_STOREOP_STORE; + // What the pass does not care to load, the tiler is told not to fetch. + _invalidate(pass->framebuffer, discard, pass->colourCount, pass->hasDepth && (depthStencilTargetInfo->load_op == SDL_GPU_LOADOP_DONT_CARE), pass->hasDepth && (depthStencilTargetInfo->stencil_load_op == SDL_GPU_LOADOP_DONT_CARE)); glViewport(0, 0, (GLsizei)pass->width, (GLsizei)pass->height); glDisable(GL_SCISSOR_TEST); // A clear has to be able to write, whatever the last pipeline left behind. @@ -623,7 +934,7 @@ static void _glesBindGraphicsPipeline(SDL_GPURenderPass *renderPass, SDL_GPUGrap pass->vertexDirty = true; } pass->pipeline = pipeline; - glUseProgram(pipeline->program); + glUseProgram(pipeline->program->name); glBindVertexArray(pipeline->vertexArray); _setPipelineState(pipeline); } @@ -663,6 +974,11 @@ static void _glesBindVertexBuffers(SDL_GPURenderPass *renderPass, Uint32 firstSl } +// The vertex stage's storage buffers arrive as buffer textures, not storage blocks. ES 3.1 lets a +// driver offer a vertex shader no storage blocks at all, and ARM's Mali drivers offer none, so the +// ES shaders are built to read the same bytes with texelFetch (scene.hlsl, SINGE_ESSL). They sit on +// units above the fragment samplers because GLES has one unit namespace for the whole program; +// _prepareSource moved the shaders' bindings up to match. static void _glesBindVertexStorageBuffers(SDL_GPURenderPass *renderPass, Uint32 firstSlot, SDL_GPUBuffer *const *storageBuffers, Uint32 numBindings) { uint32_t i; @@ -670,7 +986,8 @@ static void _glesBindVertexStorageBuffers(SDL_GPURenderPass *renderPass, Uint32 for (i = 0; i < numBindings; i++) { GlesBufferT *buffer = (GlesBufferT *)storageBuffers[i]; - glBindBufferBase(GL_SHADER_STORAGE_BUFFER, firstSlot + i, buffer != NULL ? buffer->name : 0); + glActiveTexture(GL_TEXTURE0 + GLES_VERTEX_BUFFER_UNIT + firstSlot + i); + glBindTexture(GL_TEXTURE_BUFFER, buffer != NULL ? buffer->texture : 0); } } @@ -723,9 +1040,19 @@ static SDL_GPUBuffer *_glesCreateBuffer(SDL_GPUDevice *device, const SDL_GPUBuff return NULL; } buffer->target = (createinfo->usage & SDL_GPU_BUFFERUSAGE_INDEX) != 0 ? GL_ELEMENT_ARRAY_BUFFER : ((createinfo->usage & SDL_GPU_BUFFERUSAGE_GRAPHICS_STORAGE_READ) != 0 ? GL_SHADER_STORAGE_BUFFER : GL_ARRAY_BUFFER); + buffer->size = createinfo->size; glGenBuffers(1, &buffer->name); glBindBuffer(buffer->target, buffer->name); glBufferData(buffer->target, (GLsizeiptr)createinfo->size, NULL, GL_DYNAMIC_DRAW); + if ((buffer->target == GL_SHADER_STORAGE_BUFFER) && (_device.textureBuffers != GLES_TEXTURE_BUFFER_NONE)) { + if (createinfo->size / GLES_TEXEL_BYTES > (Uint32)_device.maxTextureBufferTexels) { + utilTrace("Gles: a %u byte storage buffer is past this driver's %d texel buffer texture; reads beyond it return zero", createinfo->size, _device.maxTextureBufferTexels); + } + glGenTextures(1, &buffer->texture); + glBindTexture(GL_TEXTURE_BUFFER, buffer->texture); + glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, buffer->name); + glBindTexture(GL_TEXTURE_BUFFER, 0); + } return (SDL_GPUBuffer *)buffer; } @@ -742,7 +1069,6 @@ static SDL_GPUGraphicsPipeline *_glesCreateGraphicsPipeline(SDL_GPUDevice *devic GlesShaderT *fragment = (GlesShaderT *)createinfo->fragment_shader; GlesShaderT *vertex = (GlesShaderT *)createinfo->vertex_shader; GlesPipelineT *pipeline; - GLint linked = 0; uint32_t i; (void)device; @@ -753,27 +1079,11 @@ static SDL_GPUGraphicsPipeline *_glesCreateGraphicsPipeline(SDL_GPUDevice *devic if (pipeline == NULL) { return NULL; } - pipeline->program = glCreateProgram(); - glAttachShader(pipeline->program, vertex->name); - glAttachShader(pipeline->program, fragment->name); - glLinkProgram(pipeline->program); - glGetProgramiv(pipeline->program, GL_LINK_STATUS, &linked); - if (!linked) { - char log[GLES_LOG_BYTES] = { 0 }; - - glGetProgramInfoLog(pipeline->program, sizeof(log) - 1, NULL, log); - utilTrace("Gles: pipeline link failed: %s", log); - glDeleteProgram(pipeline->program); + pipeline->program = _programFor(vertex, fragment); + if (pipeline->program == NULL) { free(pipeline); return NULL; } - _remapFragmentBlocks(pipeline->program, fragment->source); - // GLES has no gl_BaseInstance, so SPIRV-Cross emits a plain uniform for it and expects the - // application to set it before every draw. Leaving it alone is not harmless: the scene indexes - // its instance-matrix storage buffer by gl_InstanceID + this, so an unset value reads another - // draw's matrices -- which is how the ground picked up a *billboard* flag belonging to something - // else and started turning to face whichever camera was rendering. - pipeline->baseInstance = glGetUniformLocation(pipeline->program, "SPIRV_Cross_BaseInstance"); pipeline->primitive = createinfo->primitive_type; pipeline->rasterizer = createinfo->rasterizer_state; pipeline->depthStencil = createinfo->depth_stencil_state; @@ -856,6 +1166,14 @@ static SDL_GPUShader *_glesCreateShader(SDL_GPUDevice *device, const SDL_GPUShad (void)device; if ((source == NULL) || (createinfo->format != RGPU_SHADERFORMAT_ESSL)) { utilTrace("Gles: shader %s has no GLSL ES form", createinfo->entrypoint != NULL ? createinfo->entrypoint : "?"); + SDL_SetError("no GLSL ES form"); + return NULL; + } + // Refused here rather than left to the driver's compiler, which may take it anyway (Mesa does with + // the extension hidden) and then crash in the first draw that reads an unattached buffer texture. + if ((_device.textureBuffers == GLES_TEXTURE_BUFFER_NONE) && (strstr(source, GLES_SAMPLER_BUFFER) != NULL)) { + utilTrace("Gles: shader %s needs texture buffers", createinfo->entrypoint != NULL ? createinfo->entrypoint : "?"); + SDL_SetError("this driver has no texture buffers"); return NULL; } shader = calloc(1, sizeof(GlesShaderT)); @@ -863,16 +1181,23 @@ static SDL_GPUShader *_glesCreateShader(SDL_GPUDevice *device, const SDL_GPUShad return NULL; } shader->stage = createinfo->stage == SDL_GPU_SHADERSTAGE_VERTEX ? GL_VERTEX_SHADER : GL_FRAGMENT_SHADER; - shader->source = SDL_strdup(source); + shader->serial = ++_device.shaderSerial; + shader->source = _prepareSource(source); + if (shader->source == NULL) { + free(shader); + return NULL; + } shader->name = glCreateShader(shader->stage); - glShaderSource(shader->name, 1, &source, NULL); + glShaderSource(shader->name, 1, (const GLchar *const *)&shader->source, NULL); glCompileShader(shader->name); glGetShaderiv(shader->name, GL_COMPILE_STATUS, &ok); if (!ok) { char log[GLES_LOG_BYTES] = { 0 }; glGetShaderInfoLog(shader->name, sizeof(log) - 1, NULL, log); + _trimLog(log); utilTrace("Gles: shader %s: %s", createinfo->entrypoint != NULL ? createinfo->entrypoint : "?", log); + SDL_SetError("shader compile failed: %s", log); glDeleteShader(shader->name); SDL_free(shader->source); free(shader); @@ -982,6 +1307,25 @@ static void _glesDestroyDevice(SDL_GPUDevice *device) { glDeleteBuffers(1, &_device.uniformRing); _device.uniformRing = 0; } + for (i = 0; i < GLES_PROGRAM_CACHE; i++) { + if (_device.programs[i].name != 0) { + glDeleteProgram(_device.programs[i].name); + } + } + memset(_device.programs, 0, sizeof(_device.programs)); + free(_device.binaryDirectory); + _device.binaryDirectory = NULL; + if (_device.dmabuf.available) { + glDeleteProgram(_device.dmabuf.external); + glDeleteProgram(_device.dmabuf.planes); + glDeleteTextures(1, &_device.dmabuf.externalTexture); + glDeleteTextures(2, _device.dmabuf.planeTextures); + glDeleteVertexArrays(1, &_device.dmabuf.vertexArray); + glDeleteBuffers(1, &_device.dmabuf.quad); + glDeleteFramebuffers(1, &_device.dmabuf.framebuffer); + } + free(_device.dmabuf.node); + memset(&_device.dmabuf, 0, sizeof(_device.dmabuf)); _device.ready = false; } @@ -994,8 +1338,8 @@ static void _glesDrawIndexedPrimitives(SDL_GPURenderPass *renderPass, Uint32 num if (pass->pipeline == NULL) { return; } - if (pass->pipeline->baseInstance >= 0) { - glUniform1i(pass->pipeline->baseInstance, (GLint)firstInstance); + if (pass->pipeline->program->baseInstance >= 0) { + glUniform1i(pass->pipeline->program->baseInstance, (GLint)firstInstance); } _applyVertexLayout(pass); _device.commands.issued = true; @@ -1009,8 +1353,8 @@ static void _glesDrawPrimitives(SDL_GPURenderPass *renderPass, Uint32 numVertice if (pass->pipeline == NULL) { return; } - if (pass->pipeline->baseInstance >= 0) { - glUniform1i(pass->pipeline->baseInstance, (GLint)firstInstance); + if (pass->pipeline->program->baseInstance >= 0) { + glUniform1i(pass->pipeline->program->baseInstance, (GLint)firstInstance); } _applyVertexLayout(pass); _device.commands.issued = true; @@ -1032,6 +1376,7 @@ static void _glesEndRenderPass(SDL_GPURenderPass *renderPass) { GlesRenderPassT *pass = (GlesRenderPassT *)renderPass; GLenum error; uint32_t i; + bool discard[GLES_MAX_COLOR_TARGETS]; for (i = 0; i < pass->colourCount; i++) { GlesTextureT *resolve = (GlesTextureT *)pass->colour[i].resolve_texture; @@ -1049,6 +1394,13 @@ static void _glesEndRenderPass(SDL_GPURenderPass *renderPass) { glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); } + // What the pass does not keep -- a depth buffer only this pass used, a multisampled picture once + // it is resolved -- the tiler is told not to write back. On a tile based GPU that write is a + // full screen of memory traffic per attachment per pass, for contents nobody reads. + for (i = 0; i < pass->colourCount; i++) { + discard[i] = (pass->colour[i].store_op == SDL_GPU_STOREOP_DONT_CARE) || (pass->colour[i].store_op == SDL_GPU_STOREOP_RESOLVE); + } + _invalidate(pass->framebuffer, discard, pass->colourCount, pass->hasDepth && (pass->depthStore == SDL_GPU_STOREOP_DONT_CARE), pass->hasDepth && (pass->stencilStore == SDL_GPU_STOREOP_DONT_CARE)); error = glGetError(); // One probe a pass. GLES reports asynchronously and has no debug callback in core 3.1, so this // is the only place a mistake announces itself; without it a bad pass simply draws nothing. @@ -1126,6 +1478,7 @@ static void _glesPushFragmentUniformData(SDL_GPUCommandBuffer *commandBuffer, Ui static void _glesPushUniformData(uint32_t binding, const void *data, uint32_t length) { uint32_t aligned = (uint32_t)_device.uniformAlignment; uint32_t size = (length + aligned - 1) / aligned * aligned; + void *mapped = NULL; if (_device.uniformRing == 0) { return; @@ -1140,8 +1493,19 @@ static void _glesPushUniformData(uint32_t binding, const void *data, uint32_t le } _device.uniformOffset = 0; } + // Mapped unsynchronised rather than written with glBufferSubData. Earlier draws this frame read + // other slices of the same buffer, and glBufferSubData cannot know that: ARM's drivers answer it + // by copying all GLES_UNIFORM_RING_BYTES first, once per push, which cost the menu seconds a frame + // on an R36S. Slices never overlap within a frame and the ring is orphaned between frames, so + // there is nothing to wait for. glBindBuffer(GL_UNIFORM_BUFFER, _device.uniformRing); - glBufferSubData(GL_UNIFORM_BUFFER, (GLintptr)_device.uniformOffset, (GLsizeiptr)length, data); + mapped = glMapBufferRange(GL_UNIFORM_BUFFER, (GLintptr)_device.uniformOffset, (GLsizeiptr)length, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT | GL_MAP_UNSYNCHRONIZED_BIT); + if (mapped == NULL) { + glBufferSubData(GL_UNIFORM_BUFFER, (GLintptr)_device.uniformOffset, (GLsizeiptr)length, data); + } else { + memcpy(mapped, data, length); + glUnmapBuffer(GL_UNIFORM_BUFFER); + } glBindBufferRange(GL_UNIFORM_BUFFER, binding, _device.uniformRing, (GLintptr)_device.uniformOffset, (GLsizeiptr)size); _device.uniformOffset += size; } @@ -1160,6 +1524,9 @@ static void _glesReleaseBuffer(SDL_GPUDevice *device, SDL_GPUBuffer *buffer) { if (target == NULL) { return; } + if (target->texture != 0) { + glDeleteTextures(1, &target->texture); + } glDeleteBuffers(1, &target->name); free(target); } @@ -1173,7 +1540,7 @@ static void _glesReleaseGraphicsPipeline(SDL_GPUDevice *device, SDL_GPUGraphicsP return; } glDeleteVertexArrays(1, &pipeline->vertexArray); - glDeleteProgram(pipeline->program); + _programRelease(pipeline->program); free(pipeline); } @@ -1328,8 +1695,20 @@ static void _glesUploadToBuffer(SDL_GPUCopyPass *copyPass, const SDL_GPUTransfer GlesBufferT *buffer = (GlesBufferT *)destination->buffer; (void)copyPass; - (void)cycle; glBindBuffer(buffer->target, buffer->name); + // Writing into storage a queued draw still reads makes a tiler either wait for it or copy the + // whole buffer first -- ARM's drivers copy, every time. When SDL_GPU says the old contents may + // go (cycle), or the upload replaces all of them, fresh storage is handed over instead, which is + // what cycle means on the other backends too. A buffer texture is attached again rather than + // trusting every driver to follow the buffer onto its new storage. + if (cycle || ((destination->offset == 0) && (destination->size == buffer->size))) { + glBufferData(buffer->target, (GLsizeiptr)buffer->size, NULL, GL_DYNAMIC_DRAW); + if (buffer->texture != 0) { + glBindTexture(GL_TEXTURE_BUFFER, buffer->texture); + glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, buffer->name); + glBindTexture(GL_TEXTURE_BUFFER, 0); + } + } glBufferSubData(buffer->target, (GLintptr)destination->offset, (GLsizeiptr)destination->size, transfer->data + source->offset); } @@ -1363,6 +1742,98 @@ static void _glesUploadToTexture(SDL_GPUCopyPass *copyPass, const SDL_GPUTexture } +// Tells the driver which of a framebuffer's attachments hold nothing worth keeping. Leaves the +// framebuffer bound for drawing, which is how a pass has it anyway. +static void _invalidate(GLuint framebuffer, const bool *colour, uint32_t colourCount, bool depth, bool stencil) { + GLenum attachments[GLES_MAX_COLOR_TARGETS + 2]; + GLsizei count = 0; + uint32_t i = 0; + + for (i = 0; i < colourCount; i++) { + if (colour[i]) { + attachments[count++] = GL_COLOR_ATTACHMENT0 + i; + } + } + if (depth) { + attachments[count++] = GL_DEPTH_ATTACHMENT; + } + if (stencil) { + attachments[count++] = GL_STENCIL_ATTACHMENT; + } + if ((count == 0) || (framebuffer == 0)) { + return; + } + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer); + glInvalidateFramebuffer(GL_DRAW_FRAMEBUFFER, count, attachments); +} + + +// The ES source as this driver needs it: the vertex stage's buffer textures moved up to their units +// (GLES_VERTEX_BUFFER_UNIT), and the texture buffer extension named the way the driver offers it. +// Every shader passes through here, not only the ones with buffer textures, because a program's +// stages must share one #version and the core 3.2 case changes it. SDL_malloc'd, as the shader's +// source is SDL_free'd. +static char *_prepareSource(const char *source) { + const char *bindingText = "layout(binding = "; + const char *bufferText = ") uniform highp " GLES_SAMPLER_BUFFER " "; + const char *at = source; + const char *found = NULL; + char *end = NULL; + char *edit = NULL; + char *result = NULL; + size_t used = 0; + size_t count = 0; + int32_t slot = 0; + + // Room first: each relocated binding grows by at most GLES_BINDING_DIGITS characters. + while ((found = strstr(at, bufferText)) != NULL) { + count++; + at = found + strlen(bufferText); + } + result = SDL_malloc(strlen(source) + (count * GLES_BINDING_DIGITS) + 1); + if (result == NULL) { + return NULL; + } + at = source; + while ((found = strstr(at, bindingText)) != NULL) { + found += strlen(bindingText); + memcpy(result + used, at, (size_t)(found - at)); + used += (size_t)(found - at); + at = found; + slot = (int32_t)strtol(found, &end, 10); + if ((end != found) && (strncmp(end, bufferText, strlen(bufferText)) == 0)) { + used += (size_t)SDL_snprintf(result + used, GLES_BINDING_DIGITS, "%d", slot + GLES_VERTEX_BUFFER_UNIT); + at = end; + } + } + strcpy(result + used, at); + + // The extension edits keep the length, so they are made in place. + switch (_device.textureBuffers) { + case GLES_TEXTURE_BUFFER_OES: + edit = strstr(result, GLES_EXT_TEXTURE_BUFFER); + if (edit != NULL) { + memcpy(edit, GLES_OES_TEXTURE_BUFFER, strlen(GLES_OES_TEXTURE_BUFFER)); + } + break; + case GLES_TEXTURE_BUFFER_CORE: + edit = strstr(result, GLES_VERSION_310); + if (edit != NULL) { + memcpy(edit + strlen("#version "), GLES_VERSION_NUMBER_320, strlen(GLES_VERSION_NUMBER_320)); + } + edit = strstr(result, "#extension " GLES_EXT_TEXTURE_BUFFER); + if (edit != NULL) { + memcpy(edit, "//", strlen("//")); + } + break; + default: + break; + } + + return result; +} + + static GLenum _primitive(SDL_GPUPrimitiveType type) { switch (type) { case SDL_GPU_PRIMITIVETYPE_TRIANGLESTRIP: return GL_TRIANGLE_STRIP; @@ -1374,6 +1845,193 @@ static GLenum _primitive(SDL_GPUPrimitiveType type) { } +// The program for a vertex and fragment shader pair: one already linked for another pipeline, or +// one linked now -- from the binary cache when this driver has seen the pair before. +static GlesProgramT *_programFor(GlesShaderT *vertex, GlesShaderT *fragment) { + GlesProgramT *program = NULL; + uint64_t key = 0; + uint64_t startNS = SDL_GetTicksNS(); + GLint linked = 0; + bool loaded = false; + uint32_t x = 0; + + for (x = 0; x < GLES_PROGRAM_CACHE; x++) { + if ((_device.programs[x].name != 0) && (_device.programs[x].vertexSerial == vertex->serial) && (_device.programs[x].fragmentSerial == fragment->serial)) { + _device.programs[x].references++; + return &_device.programs[x]; + } + if ((program == NULL) && (_device.programs[x].name == 0)) { + program = &_device.programs[x]; + } + } + if (program == NULL) { + SDL_SetError("more than %d shader pairs", GLES_PROGRAM_CACHE); + return NULL; + } + program->name = glCreateProgram(); + glAttachShader(program->name, vertex->name); + glAttachShader(program->name, fragment->name); + key = _programKey(vertex, fragment); + loaded = _programLoad(program->name, key); + if (!loaded) { + if (_device.binaries) { + glProgramParameteri(program->name, GL_PROGRAM_BINARY_RETRIEVABLE_HINT, GL_TRUE); + } + glLinkProgram(program->name); + glGetProgramiv(program->name, GL_LINK_STATUS, &linked); + if (!linked) { + char log[GLES_LOG_BYTES] = { 0 }; + + glGetProgramInfoLog(program->name, sizeof(log) - 1, NULL, log); + _trimLog(log); + utilTrace("Gles: pipeline link failed: %s", log); + // The caller reports SDL_GetError(), which would otherwise be whatever SDL last complained about. + SDL_SetError("pipeline link failed: %s", log); + glDeleteProgram(program->name); + program->name = 0; + return NULL; + } + _programSave(program->name, key); + } + // A mobile driver compiles for real at link, and a first frame waits on every program it needs. + utilTrace("Gles: program %s in %" PRIu64 " ms", loaded ? "loaded from the cache" : "linked", (SDL_GetTicksNS() - startNS) / SDL_NS_PER_MS); + // Block bindings are program state that neither the source nor a binary is guaranteed to carry. + _remapFragmentBlocks(program->name, fragment->source); + // GLES has no gl_BaseInstance, so SPIRV-Cross emits a plain uniform for it and expects the + // application to set it before every draw. Leaving it alone is not harmless: the scene indexes + // its instance matrices by gl_InstanceID + this, so an unset value reads another draw's matrices + // -- which is how the ground picked up a *billboard* flag belonging to something else and started + // turning to face whichever camera was rendering. + program->baseInstance = glGetUniformLocation(program->name, "SPIRV_Cross_BaseInstance"); + program->vertexSerial = vertex->serial; + program->fragmentSerial = fragment->serial; + program->references = 1; + + return program; +} + + +// Names a cached binary: both sources as this driver was given them, and the driver itself, since a +// binary from another driver or another version of this one is refused anyway. +static uint64_t _programKey(const GlesShaderT *vertex, const GlesShaderT *fragment) { + const char *parts[4]; + const char *at = NULL; + uint64_t hash = GLES_FNV_OFFSET; + uint32_t x = 0; + + parts[0] = vertex->source; + parts[1] = fragment->source; + parts[2] = (const char *)glGetString(GL_RENDERER); + parts[3] = (const char *)glGetString(GL_VERSION); + for (x = 0; x < SDL_arraysize(parts); x++) { + // The terminator is hashed too, so "ab" + "c" and "a" + "bc" do not name the same program. + for (at = (parts[x] != NULL) ? parts[x] : ""; ; at++) { + hash = (hash ^ (uint8_t)*at) * GLES_FNV_PRIME; + if (*at == '\0') { + break; + } + } + } + + return hash; +} + + +// Links a program from its cached binary. False when there is none, or the driver refuses it -- +// after an update, say -- and then the shaders still attached are linked the ordinary way. +static bool _programLoad(GLuint program, uint64_t key) { + GlesBinaryHeaderT header; + char *path = NULL; + char *data = NULL; + size_t bytes = 0; + GLint linked = 0; + + if (!_device.binaries || (_device.binaryDirectory == NULL)) { + return false; + } + path = utilCreateString("%s" GLES_BINARY_NAME, _device.binaryDirectory, key); + data = utilReadFile(path, &bytes); + free(path); + if (data == NULL) { + return false; + } + if (bytes >= sizeof(header)) { + memcpy(&header, data, sizeof(header)); + if ((memcmp(header.magic, GLES_BINARY_MAGIC, GLES_BINARY_MAGIC_BYTES) == 0) && (header.length == bytes - sizeof(header))) { + glProgramBinary(program, header.format, data + sizeof(header), (GLsizei)header.length); + glGetProgramiv(program, GL_LINK_STATUS, &linked); + } + } + free(data); + // A refused binary leaves an error behind; it is not this caller's to report. + while (glGetError() != GL_NO_ERROR) { + } + + return linked != 0; +} + + +static void _programRelease(GlesProgramT *program) { + if ((program == NULL) || (program->references == 0)) { + return; + } + program->references--; + if (program->references == 0) { + glDeleteProgram(program->name); + memset(program, 0, sizeof(GlesProgramT)); + } +} + + +// Keeps a freshly linked program for next time. Written beside and then renamed over, so a run that +// dies half way through leaves either the old file or none, never half of one. Failing to write is +// not an error: the next run links again. +static void _programSave(GLuint program, uint64_t key) { + GlesBinaryHeaderT header; + char *path = NULL; + char *temporary = NULL; + uint8_t *data = NULL; + FILE *out = NULL; + GLint length = 0; + GLsizei written = 0; + GLenum format = 0; + bool ok = false; + + if (!_device.binaries || (_device.binaryDirectory == NULL)) { + return; + } + glGetProgramiv(program, GL_PROGRAM_BINARY_LENGTH, &length); + if (length <= 0) { + return; + } + data = malloc((size_t)length); + if (data == NULL) { + return; + } + glGetProgramBinary(program, length, &written, &format, data); + if (written > 0) { + memcpy(header.magic, GLES_BINARY_MAGIC, GLES_BINARY_MAGIC_BYTES); + header.format = (uint32_t)format; + header.length = (uint32_t)written; + utilMkDirP(_device.binaryDirectory, GLES_BINARY_DIRECTORY_MODE); + path = utilCreateString("%s" GLES_BINARY_NAME, _device.binaryDirectory, key); + temporary = utilCreateString("%s" GLES_BINARY_TEMPORARY, path); + out = fopen(temporary, "wb"); + if (out != NULL) { + ok = (fwrite(&header, sizeof(header), 1, out) == 1) && (fwrite(data, (size_t)written, 1, out) == 1); + ok = (fclose(out) == 0) && ok; + remove(path); + if (!ok || (rename(temporary, path) != 0)) { + remove(temporary); + } + } + free(temporary); + free(path); + } + free(data); +} + + // HLSL space1 (vertex) and space3 (fragment) both start their uniform blocks at binding 0, and a GLES // program has one binding namespace, so the two would read the same buffer. The fragment shader's // blocks are moved up. The names SPIRV-Cross emits are stable ("type_FragmentUniforms"), and the @@ -1497,6 +2155,22 @@ static GLenum _stencilOp(SDL_GPUStencilOp op) { } +// Drivers pad their logs with blank space at either end, and the trace line adds its own. +static void _trimLog(char *log) { + size_t start = 0; + size_t length = strlen(log); + + while ((length > 0) && SDL_isspace((unsigned char)log[length - 1])) { + length--; + } + while ((start < length) && SDL_isspace((unsigned char)log[start])) { + start++; + } + memmove(log, log + start, length - start); + log[length - start] = '\0'; +} + + static void _vertexFormat(SDL_GPUVertexElementFormat format, GLint *size, GLenum *type, GLboolean *normalized) { *size = 4; *type = GL_FLOAT; @@ -1540,6 +2214,156 @@ static void _vertexFormat(SDL_GPUVertexElementFormat format, GLint *size, GLenum // Leave the context as it was found. render.c calls this after every frame's drawing: SDL's own // cached state was invalidated by SDL_FlushRenderer on the way in, but the bindings below are // container state SDL does not track, so they are put back by hand. +bool renderGlesDmabufAvailable(void) { + if (!_device.ready) { + return false; + } + if (!_device.dmabuf.checked) { + _dmabufStart(); + } + + return _device.dmabuf.available; +} + + +// One frame from its dma-buf into target. The images are only needed while the textures are bound to +// them, so they go at once; the buffer under them belongs to the decoder, which the caller keeps from +// reusing it until this draw has long finished. +bool renderGlesDmabufConvert(const RenderDmabufT *frame, SDL_GPUTexture *target) { + static const GLfloat rows601[GLES_YUV_ROWS * 3] = { 1.0f, 0.0f, 1.402f, 1.0f, -0.344136f, -0.714136f, 1.0f, 1.772f, 0.0f }; + static const GLfloat rows709[GLES_YUV_ROWS * 3] = { 1.0f, 0.0f, 1.5748f, 1.0f, -0.187324f, -0.468124f, 1.0f, 1.8556f, 0.0f }; + static const GLfloat rows2020[GLES_YUV_ROWS * 3] = { 1.0f, 0.0f, 1.4746f, 1.0f, -0.164553f, -0.571353f, 1.0f, 1.8814f, 0.0f }; + GlesTextureT *out = (GlesTextureT *)target; + const GLfloat *rows = (frame != NULL) && (frame->colorSpace == RENDER_COLORSPACE_BT709) ? rows709 : ((frame != NULL) && (frame->colorSpace == RENDER_COLORSPACE_BT2020) ? rows2020 : rows601); + GLfloat scaled[GLES_YUV_ROWS * 3]; + GLfloat offset[3]; + EGLImageKHR luma = EGL_NO_IMAGE_KHR; + EGLImageKHR chroma = EGL_NO_IMAGE_KHR; + EGLImageKHR image = EGL_NO_IMAGE_KHR; + GLenum error = GL_NO_ERROR; + int32_t i = 0; + + if (!renderGlesDmabufAvailable() || (frame == NULL) || (out == NULL) || (frame->fourcc != GLES_DRM_NV12) || (frame->planes != 2)) { + return false; + } + while (glGetError() != GL_NO_ERROR) { + } + glBindSampler(0, 0); + glBindSampler(1, 0); + + // The whole frame first, until the driver has refused it once. + if (!_device.dmabuf.planar) { + image = _dmabufImage(frame, GLES_DRM_NV12, frame->width, frame->height, 0, 2); + if (image == EGL_NO_IMAGE_KHR) { + _device.dmabuf.planar = true; + utilTrace("Gles: the driver will not take NV12 whole (EGL error 0x%04X); importing its planes apart", (_device.dmabuf.getError != NULL) ? (uint32_t)_device.dmabuf.getError() : 0u); + } else { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_EXTERNAL_OES, _device.dmabuf.externalTexture); + glEGLImageTargetTexture2DOES(GL_TEXTURE_EXTERNAL_OES, image); + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glUseProgram(_device.dmabuf.external); + glUniform1i(_device.dmabuf.externalPicture, 0); + _dmabufDraw(_device.dmabuf.external, out); + glBindTexture(GL_TEXTURE_EXTERNAL_OES, 0); + error = glGetError(); + _device.dmabuf.destroyImage(_device.dmabuf.display, image); + return error == GL_NO_ERROR; + } + } + + // The planes apart: luma at full size, the interleaved chroma at half each way. + luma = _dmabufImage(frame, GLES_DRM_R8, frame->width, frame->height, 0, 1); + chroma = _dmabufImage(frame, GLES_DRM_GR88, (frame->width + 1) / 2, (frame->height + 1) / 2, 1, 1); + if ((luma == EGL_NO_IMAGE_KHR) || (chroma == EGL_NO_IMAGE_KHR)) { + utilTrace("Gles: EGL would not import the planes of a %dx%d dma-buf frame, modifier 0x%016" PRIx64 " (EGL error 0x%04X)", frame->width, frame->height, frame->modifier, (_device.dmabuf.getError != NULL) ? (uint32_t)_device.dmabuf.getError() : 0u); + if (luma != EGL_NO_IMAGE_KHR) { + _device.dmabuf.destroyImage(_device.dmabuf.display, luma); + } + if (chroma != EGL_NO_IMAGE_KHR) { + _device.dmabuf.destroyImage(_device.dmabuf.display, chroma); + } + return false; + } + for (i = 0; i < 2; i++) { + glActiveTexture(GL_TEXTURE0 + (GLenum)i); + glBindTexture(GL_TEXTURE_2D, _device.dmabuf.planeTextures[i]); + glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, (i == 0) ? luma : chroma); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } + // The range folded into the matrix: limited range stretches luma from 16..235 and chroma from 16..240. + for (i = 0; i < GLES_YUV_ROWS; i++) { + scaled[i * 3] = rows[i * 3] * (frame->fullRange ? 1.0f : GLES_LIMITED_LUMA_SCALE); + scaled[i * 3 + 1] = rows[i * 3 + 1] * (frame->fullRange ? 1.0f : GLES_LIMITED_CHROMA_SCALE); + scaled[i * 3 + 2] = rows[i * 3 + 2] * (frame->fullRange ? 1.0f : GLES_LIMITED_CHROMA_SCALE); + } + offset[0] = frame->fullRange ? 0.0f : GLES_LIMITED_BLACK; + offset[1] = GLES_CHROMA_ZERO; + offset[2] = GLES_CHROMA_ZERO; + glUseProgram(_device.dmabuf.planes); + glUniform1i(_device.dmabuf.planesLuma, 0); + glUniform1i(_device.dmabuf.planesChroma, 1); + glUniform3fv(_device.dmabuf.planesRows, GLES_YUV_ROWS, scaled); + glUniform3fv(_device.dmabuf.planesOffset, 1, offset); + _dmabufDraw(_device.dmabuf.planes, out); + for (i = 1; i >= 0; i--) { + glActiveTexture(GL_TEXTURE0 + (GLenum)i); + glBindTexture(GL_TEXTURE_2D, 0); + } + error = glGetError(); + _device.dmabuf.destroyImage(_device.dmabuf.display, luma); + _device.dmabuf.destroyImage(_device.dmabuf.display, chroma); + if (error != GL_NO_ERROR) { + utilTrace("Gles: converting a video frame failed with error 0x%04X", (uint32_t)error); + return false; + } + + return true; +} + + +// An RGBA texture to convert frames into, an ordinary one of this backend's. +SDL_GPUTexture *renderGlesDmabufTarget(int32_t width, int32_t height) { + SDL_GPUTextureCreateInfo info; + + memset(&info, 0, sizeof(info)); + info.type = SDL_GPU_TEXTURETYPE_2D; + info.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + info.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER; + info.width = (Uint32)width; + info.height = (Uint32)height; + info.layer_count_or_depth = 1; + info.num_levels = 1; + info.sample_count = SDL_GPU_SAMPLECOUNT_1; + + return _glesCreateTexture(NULL, &info); +} + + +// The DRM node the display draws on, for a decoder to open so its frames can be imported; NULL when +// zero-copy is off or EGL cannot say. +const char *renderGlesDmabufNode(void) { + return renderGlesDmabufAvailable() ? _device.dmabuf.node : NULL; +} + + +void renderGlesDmabufTargetFree(SDL_GPUTexture *target) { + _glesReleaseTexture(NULL, target); +} + + +// Waits until the GPU has done everything asked of it so far, for --perfsync. +void renderGlesFinish(void) { + glFinish(); +} + + void renderGlesRestore(void) { uint32_t unit; @@ -1569,10 +2393,11 @@ void renderGlesRestore(void) { // Stand the backend up inside the context SDL_Renderer already made. Fails, rather than half // starts, when the context is not GLES 3.1 or the uniform ring cannot be built. -bool renderGlesStart(void) { +bool renderGlesStart(const char *cacheDirectory) { const char *version; - GLint major = 0; - GLint minor = 0; + GLint major = 0; + GLint minor = 0; + GLint formats = 0; memset(&_device, 0, sizeof(_device)); if (SDL_GL_GetCurrentContext() == NULL) { @@ -1607,7 +2432,32 @@ bool renderGlesStart(void) { utilTrace("Gles: anisotropy %s", _device.maxAnisotropy > 1.0f ? "available" : "not offered"); _device.astc = (extensions != NULL) && (strstr(extensions, "GL_KHR_texture_compression_astc_ldr") != NULL); utilTrace("Gles: block textures: ETC2, %s", _device.astc ? "and ASTC 4x4" : "no ASTC"); + if ((extensions != NULL) && (strstr(extensions, GLES_EXT_TEXTURE_BUFFER) != NULL)) { + _device.textureBuffers = GLES_TEXTURE_BUFFER_EXT; + } else if ((extensions != NULL) && (strstr(extensions, GLES_OES_TEXTURE_BUFFER) != NULL)) { + _device.textureBuffers = GLES_TEXTURE_BUFFER_OES; + } else if ((major > 3) || (minor >= 2)) { + _device.textureBuffers = GLES_TEXTURE_BUFFER_CORE; + } + // A driver hands back a pointer for names it does not have, so the entry point alone proves + // nothing; its absence does rule them out. + if (glTexBuffer == NULL) { + _device.textureBuffers = GLES_TEXTURE_BUFFER_NONE; + } + if (_device.textureBuffers == GLES_TEXTURE_BUFFER_NONE) { + utilTrace("Gles: no texture buffers; the 3D scene cannot start (the GUI can)"); + } else { + glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE, &_device.maxTextureBufferTexels); + utilTrace("Gles: texture buffers up to %d texels", _device.maxTextureBufferTexels); + } } + // Linked programs kept between runs, when the driver hands binaries out at all. + glGetIntegerv(GL_NUM_PROGRAM_BINARY_FORMATS, &formats); + _device.binaries = formats > 0; + if (_device.binaries && (cacheDirectory != NULL)) { + _device.binaryDirectory = strdup(cacheDirectory); + } + utilTrace("Gles: program binaries %s", _device.binaries ? ((_device.binaryDirectory != NULL) ? "cached between runs" : "offered but not kept") : "not offered"); glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &_device.uniformAlignment); if (_device.uniformAlignment <= 0) { _device.uniformAlignment = 256; @@ -1619,7 +2469,7 @@ bool renderGlesStart(void) { while (glGetError() != GL_NO_ERROR) { } _device.ready = true; - utilTrace("Render: OpenGL ES ready (%s)", version); + utilTrace("Render: OpenGL ES ready (%s on %s)", version, (const char *)glGetString(GL_RENDERER)); return true; } diff --git a/src/renderGlesApi.h b/src/renderGlesApi.h index 379fdd7d5..2d09ce763 100644 --- a/src/renderGlesApi.h +++ b/src/renderGlesApi.h @@ -85,6 +85,9 @@ typedef intptr_t GLsizeiptr; #define GL_DEPTH24_STENCIL8 0x88F0 #define GL_DEPTH32F_STENCIL8 0x8CAD #define GL_DEPTH_ATTACHMENT 0x8D00 +#define GL_STENCIL_ATTACHMENT 0x8D20 +#define GL_STATIC_DRAW 0x88E4 +#define GL_TEXTURE_EXTERNAL_OES 0x8D65 #define GL_DEPTH_BUFFER_BIT 0x00000100 #define GL_DEPTH_COMPONENT 0x1902 #define GL_DEPTH_COMPONENT16 0x81A5 @@ -170,8 +173,17 @@ typedef intptr_t GLsizeiptr; #define GL_TEXTURE0 0x84C0 #define GL_TEXTURE_2D 0x0DE1 #define GL_TEXTURE_2D_ARRAY 0x8C1A +#define GL_TEXTURE_BUFFER 0x8C2A +#define GL_MAP_WRITE_BIT 0x0002 +#define GL_MAP_INVALIDATE_RANGE_BIT 0x0004 +#define GL_MAP_UNSYNCHRONIZED_BIT 0x0020 +#define GL_RENDERER 0x1F01 +#define GL_PROGRAM_BINARY_RETRIEVABLE_HINT 0x8257 +#define GL_PROGRAM_BINARY_LENGTH 0x8741 +#define GL_NUM_PROGRAM_BINARY_FORMATS 0x87FE #define GL_TEXTURE_3D 0x806F #define GL_EXTENSIONS 0x1F03 +#define GL_MAX_TEXTURE_BUFFER_SIZE 0x8C2B #define GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT 0x84FF #define GL_TEXTURE_BASE_LEVEL 0x813C #define GL_TEXTURE_MAX_ANISOTROPY_EXT 0x84FE @@ -245,6 +257,7 @@ typedef void (GL_APIENTRYP GlesDrawBuffersFn)(GLsizei n, const GLenum *bufs); typedef void (GL_APIENTRYP GlesDrawElementsInstancedFn)(GLenum mode, GLsizei count, GLenum type, const void *indices, GLsizei instancecount); typedef void (GL_APIENTRYP GlesEnableFn)(GLenum cap); typedef void (GL_APIENTRYP GlesEnableVertexAttribArrayFn)(GLuint index); +typedef void (GL_APIENTRYP GlesFinishFn)(void); typedef void (GL_APIENTRYP GlesFlushFn)(void); typedef void (GL_APIENTRYP GlesFramebufferRenderbufferFn)(GLenum target, GLenum attachment, GLenum renderbuffertarget, GLuint renderbuffer); typedef void (GL_APIENTRYP GlesFramebufferTexture2DFn)(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level); @@ -266,7 +279,17 @@ typedef void (GL_APIENTRYP GlesGetShaderInfoLogFn)(GLuint shader, GLsizei bufSiz typedef void (GL_APIENTRYP GlesGetShaderivFn)(GLuint shader, GLenum pname, GLint *params); typedef const GLubyte * (GL_APIENTRYP GlesGetStringFn)(GLenum name); typedef GLuint (GL_APIENTRYP GlesGetUniformBlockIndexFn)(GLuint program, const GLchar *uniformBlockName); +typedef void (GL_APIENTRYP GlesInvalidateFramebufferFn)(GLenum target, GLsizei numAttachments, const GLenum *attachments); +typedef void (GL_APIENTRYP GlesDisableVertexAttribArrayFn)(GLuint index); +typedef void (GL_APIENTRYP GlesBindAttribLocationFn)(GLuint program, GLuint index, const GLchar *name); +typedef void (GL_APIENTRYP GlesUniform3fvFn)(GLint location, GLsizei count, const GLfloat *value); +typedef void (GL_APIENTRYP GlesEGLImageTargetTexture2DOESFn)(GLenum target, void *image); typedef void (GL_APIENTRYP GlesLinkProgramFn)(GLuint program); +typedef void *(GL_APIENTRYP GlesMapBufferRangeFn)(GLenum target, GLintptr offset, GLsizeiptr length, GLbitfield access); +typedef GLboolean (GL_APIENTRYP GlesUnmapBufferFn)(GLenum target); +typedef void (GL_APIENTRYP GlesGetProgramBinaryFn)(GLuint program, GLsizei bufSize, GLsizei *length, GLenum *binaryFormat, void *binary); +typedef void (GL_APIENTRYP GlesProgramBinaryFn)(GLuint program, GLenum binaryFormat, const void *binary, GLsizei length); +typedef void (GL_APIENTRYP GlesProgramParameteriFn)(GLuint program, GLenum pname, GLint value); typedef void (GL_APIENTRYP GlesPixelStoreiFn)(GLenum pname, GLint param); typedef void (GL_APIENTRYP GlesPolygonOffsetFn)(GLfloat factor, GLfloat units); typedef void (GL_APIENTRYP GlesReadBufferFn)(GLenum src); @@ -282,6 +305,7 @@ typedef void (GL_APIENTRYP GlesStencilFuncSeparateFn)(GLenum face, GLenum func, typedef void (GL_APIENTRYP GlesStencilMaskFn)(GLuint mask); typedef void (GL_APIENTRYP GlesStencilMaskSeparateFn)(GLenum face, GLuint mask); typedef void (GL_APIENTRYP GlesStencilOpSeparateFn)(GLenum face, GLenum sfail, GLenum dpfail, GLenum dppass); +typedef void (GL_APIENTRYP GlesTexBufferFn)(GLenum target, GLenum internalformat, GLuint buffer); typedef void (GL_APIENTRYP GlesTexParameteriFn)(GLenum target, GLenum pname, GLint param); typedef void (GL_APIENTRYP GlesTexStorage2DFn)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height); typedef void (GL_APIENTRYP GlesTexStorage3DFn)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height, GLsizei depth); @@ -339,6 +363,7 @@ typedef struct GlesApiS { GlesDrawElementsInstancedFn drawElementsInstanced; GlesEnableFn enable; GlesEnableVertexAttribArrayFn enableVertexAttribArray; + GlesFinishFn finish; GlesFlushFn flush; GlesFramebufferRenderbufferFn framebufferRenderbuffer; GlesFramebufferTexture2DFn framebufferTexture2D; @@ -361,6 +386,15 @@ typedef struct GlesApiS { GlesGetStringFn getString; GlesGetUniformBlockIndexFn getUniformBlockIndex; GlesLinkProgramFn linkProgram; + GlesInvalidateFramebufferFn invalidateFramebuffer; + GlesDisableVertexAttribArrayFn disableVertexAttribArray; + GlesBindAttribLocationFn bindAttribLocation; + GlesUniform3fvFn uniform3fv; + GlesMapBufferRangeFn mapBufferRange; + GlesUnmapBufferFn unmapBuffer; + GlesGetProgramBinaryFn getProgramBinary; + GlesProgramBinaryFn programBinary; + GlesProgramParameteriFn programParameteri; GlesPixelStoreiFn pixelStorei; GlesPolygonOffsetFn polygonOffset; GlesReadBufferFn readBuffer; @@ -387,6 +421,11 @@ typedef struct GlesApiS { GlesVertexAttribIPointerFn vertexAttribIPointer; GlesVertexAttribPointerFn vertexAttribPointer; GlesViewportFn viewport; + // Optional, so after everything renderGlesLoad insists on: core in ES 3.2, an extension before + // it, and only the 3D scene needs it. NULL means no texture buffers and so no scene. + GlesTexBufferFn texBuffer; + // GL_OES_EGL_image, for zero-copy video; NULL without it, and then frames are copied. + GlesEGLImageTargetTexture2DOESFn eglImageTargetTexture2DOES; } GlesApiT; @@ -440,6 +479,7 @@ bool renderGlesLoad(void); #define glDrawElementsInstanced _glesApi.drawElementsInstanced #define glEnable _glesApi.enable #define glEnableVertexAttribArray _glesApi.enableVertexAttribArray +#define glFinish _glesApi.finish #define glFlush _glesApi.flush #define glFramebufferRenderbuffer _glesApi.framebufferRenderbuffer #define glFramebufferTexture2D _glesApi.framebufferTexture2D @@ -461,7 +501,17 @@ bool renderGlesLoad(void); #define glGetShaderiv _glesApi.getShaderiv #define glGetString _glesApi.getString #define glGetUniformBlockIndex _glesApi.getUniformBlockIndex +#define glInvalidateFramebuffer _glesApi.invalidateFramebuffer +#define glDisableVertexAttribArray _glesApi.disableVertexAttribArray +#define glBindAttribLocation _glesApi.bindAttribLocation +#define glUniform3fv _glesApi.uniform3fv +#define glEGLImageTargetTexture2DOES _glesApi.eglImageTargetTexture2DOES #define glLinkProgram _glesApi.linkProgram +#define glMapBufferRange _glesApi.mapBufferRange +#define glUnmapBuffer _glesApi.unmapBuffer +#define glGetProgramBinary _glesApi.getProgramBinary +#define glProgramBinary _glesApi.programBinary +#define glProgramParameteri _glesApi.programParameteri #define glPixelStorei _glesApi.pixelStorei #define glPolygonOffset _glesApi.polygonOffset #define glReadBuffer _glesApi.readBuffer @@ -477,6 +527,7 @@ bool renderGlesLoad(void); #define glStencilMask _glesApi.stencilMask #define glStencilMaskSeparate _glesApi.stencilMaskSeparate #define glStencilOpSeparate _glesApi.stencilOpSeparate +#define glTexBuffer _glesApi.texBuffer #define glTexParameteri _glesApi.texParameteri #define glTexStorage2D _glesApi.texStorage2D #define glTexStorage3D _glesApi.texStorage3D diff --git a/src/renderGlesLoad.c b/src/renderGlesLoad.c index 087ffbb4a..ccd33aa75 100644 --- a/src/renderGlesLoad.c +++ b/src/renderGlesLoad.c @@ -75,6 +75,7 @@ bool renderGlesLoad(void) { _glesApi.drawElementsInstanced = (GlesDrawElementsInstancedFn)SDL_GL_GetProcAddress("glDrawElementsInstanced"); _glesApi.enable = (GlesEnableFn)SDL_GL_GetProcAddress("glEnable"); _glesApi.enableVertexAttribArray = (GlesEnableVertexAttribArrayFn)SDL_GL_GetProcAddress("glEnableVertexAttribArray"); + _glesApi.finish = (GlesFinishFn)SDL_GL_GetProcAddress("glFinish"); _glesApi.flush = (GlesFlushFn)SDL_GL_GetProcAddress("glFlush"); _glesApi.framebufferRenderbuffer = (GlesFramebufferRenderbufferFn)SDL_GL_GetProcAddress("glFramebufferRenderbuffer"); _glesApi.framebufferTexture2D = (GlesFramebufferTexture2DFn)SDL_GL_GetProcAddress("glFramebufferTexture2D"); @@ -97,6 +98,15 @@ bool renderGlesLoad(void) { _glesApi.getString = (GlesGetStringFn)SDL_GL_GetProcAddress("glGetString"); _glesApi.getUniformBlockIndex = (GlesGetUniformBlockIndexFn)SDL_GL_GetProcAddress("glGetUniformBlockIndex"); _glesApi.linkProgram = (GlesLinkProgramFn)SDL_GL_GetProcAddress("glLinkProgram"); + _glesApi.invalidateFramebuffer = (GlesInvalidateFramebufferFn)SDL_GL_GetProcAddress("glInvalidateFramebuffer"); + _glesApi.disableVertexAttribArray = (GlesDisableVertexAttribArrayFn)SDL_GL_GetProcAddress("glDisableVertexAttribArray"); + _glesApi.bindAttribLocation = (GlesBindAttribLocationFn)SDL_GL_GetProcAddress("glBindAttribLocation"); + _glesApi.uniform3fv = (GlesUniform3fvFn)SDL_GL_GetProcAddress("glUniform3fv"); + _glesApi.mapBufferRange = (GlesMapBufferRangeFn)SDL_GL_GetProcAddress("glMapBufferRange"); + _glesApi.unmapBuffer = (GlesUnmapBufferFn)SDL_GL_GetProcAddress("glUnmapBuffer"); + _glesApi.getProgramBinary = (GlesGetProgramBinaryFn)SDL_GL_GetProcAddress("glGetProgramBinary"); + _glesApi.programBinary = (GlesProgramBinaryFn)SDL_GL_GetProcAddress("glProgramBinary"); + _glesApi.programParameteri = (GlesProgramParameteriFn)SDL_GL_GetProcAddress("glProgramParameteri"); _glesApi.pixelStorei = (GlesPixelStoreiFn)SDL_GL_GetProcAddress("glPixelStorei"); _glesApi.polygonOffset = (GlesPolygonOffsetFn)SDL_GL_GetProcAddress("glPolygonOffset"); _glesApi.readBuffer = (GlesReadBufferFn)SDL_GL_GetProcAddress("glReadBuffer"); @@ -123,6 +133,14 @@ bool renderGlesLoad(void) { _glesApi.vertexAttribIPointer = (GlesVertexAttribIPointerFn)SDL_GL_GetProcAddress("glVertexAttribIPointer"); _glesApi.vertexAttribPointer = (GlesVertexAttribPointerFn)SDL_GL_GetProcAddress("glVertexAttribPointer"); _glesApi.viewport = (GlesViewportFn)SDL_GL_GetProcAddress("glViewport"); + _glesApi.texBuffer = (GlesTexBufferFn)SDL_GL_GetProcAddress("glTexBuffer"); + if (_glesApi.texBuffer == NULL) { + _glesApi.texBuffer = (GlesTexBufferFn)SDL_GL_GetProcAddress("glTexBufferEXT"); + } + if (_glesApi.texBuffer == NULL) { + _glesApi.texBuffer = (GlesTexBufferFn)SDL_GL_GetProcAddress("glTexBufferOES"); + } + _glesApi.eglImageTargetTexture2DOES = (GlesEGLImageTargetTexture2DOESFn)SDL_GL_GetProcAddress("glEGLImageTargetTexture2DOES"); { const void *const *slot = (const void *const *)&_glesApi; @@ -170,6 +188,7 @@ bool renderGlesLoad(void) { "glDrawElementsInstanced", "glEnable", "glEnableVertexAttribArray", + "glFinish", "glFlush", "glFramebufferRenderbuffer", "glFramebufferTexture2D", @@ -192,6 +211,15 @@ bool renderGlesLoad(void) { "glGetString", "glGetUniformBlockIndex", "glLinkProgram", + "glInvalidateFramebuffer", + "glDisableVertexAttribArray", + "glBindAttribLocation", + "glUniform3fv", + "glMapBufferRange", + "glUnmapBuffer", + "glGetProgramBinary", + "glProgramBinary", + "glProgramParameteri", "glPixelStorei", "glPolygonOffset", "glReadBuffer", diff --git a/src/scene.c b/src/scene.c index 846ca5112..0ebd38665 100644 --- a/src/scene.c +++ b/src/scene.c @@ -53,6 +53,7 @@ #define VIEW_SIZE_MAX 4096 // Pixels per side of a rendered view #define TEXTURE_SIZES_STEP 64 // Growth of the texture size table #define DEFAULT_BLOOM_THRESHOLD 1.0f +#define LOW_QUALITY_DIVISOR 2 // --scenequality low renders the scene at half the overlay's size each way #define MATERIAL_SAMPLERS 7 // Base, shadows, normal, occlusion, metallic-roughness, emissive, sky #define FRAME_UNIFORMS 0 // Fragment uniform slots: the pass's FragmentUniformsT ... #define MATERIAL_UNIFORMS 1 // ... and the batch's MaterialUniformsT @@ -510,6 +511,7 @@ typedef struct SceneS { int32_t quadMesh; // The unit quad sprites and text draw on, NO_HANDLE until needed float bloomThreshold; float bloomStrength; // 0: no bloom + bool lowQuality; // --scenequality low: half resolution, no antialiasing and no bloom, whatever the script asks SDL_GPUTexture *bloomDown[BLOOM_LEVELS]; // The half-size chain down ... SDL_GPUTexture *bloomUp[BLOOM_LEVELS]; // ... and back up, one texture per level int32_t bloomLevels; @@ -580,9 +582,12 @@ typedef struct SceneS { float near; float far; Mat4T viewProjection; // Of the last rendered frame, for sceneProject - int32_t width; + int32_t width; // The overlay's size, which projection and the script's coordinates use int32_t height; + int32_t renderWidth; // The targets' size: the overlay's, or a part of it at --scenequality low + int32_t renderHeight; bool enabled; + bool prewarmed; } SceneT; @@ -659,6 +664,7 @@ static void _orderBlended(Vec3T eye, int32_t drawCount); static ParticleTexturesT *_particleTextures(const EmitterViewT *view); static SDL_GPUTextureFormat _pickDepthFormat(const SDL_GPUTextureFormat *wanted, int32_t count, SDL_GPUTextureUsageFlags usage); static int32_t _pipelineVariant(int32_t node); +static void _prewarmPipelines(void); static Mat4T _projectionFor(int32_t width, int32_t height); static int32_t _quadMesh(float width, float height, Vec3T down, Vec3T normal); static void _recordTexture(SDL_GPUTexture *texture, size_t bytes); @@ -1348,8 +1354,8 @@ static SDL_GPUTexture *_createShadowArray(SDL_GPUTextureType type, int32_t layer info.type = type; info.format = _scene.shadowFormat; info.usage = SDL_GPU_TEXTUREUSAGE_DEPTH_STENCIL_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER; - info.width = (Uint32)((size > 0) ? size : _scene.width); - info.height = (Uint32)((size > 0) ? size : _scene.height); + info.width = (Uint32)((size > 0) ? size : _scene.renderWidth); + info.height = (Uint32)((size > 0) ? size : _scene.renderHeight); info.layer_count_or_depth = (Uint32)layers; info.num_levels = 1; info.sample_count = SDL_GPU_SAMPLECOUNT_1; @@ -1606,6 +1612,7 @@ static void _destroyPipelines(void) { int32_t set; int32_t x; + _scene.prewarmed = false; for (set = 0; set < SAMPLE_SETS; set++) { for (x = 0; x < PIPELINE_COUNT; x++) { _releasePipeline(&_scene.pipelines[set][x]); @@ -1654,8 +1661,10 @@ static void _destroyTargets(void) { _scene.depth = NULL; } _releaseTexture(&_scene.softDepth); - _scene.width = 0; - _scene.height = 0; + _scene.width = 0; + _scene.height = 0; + _scene.renderWidth = 0; + _scene.renderHeight = 0; } @@ -2041,7 +2050,7 @@ static void _drawPost(SDL_GPUCommandBuffer *commands, const CameraFrameT *frame) SDL_GPURenderPass *pass; SDL_GPUTextureSamplerBinding samplers[2]; PostUniformsT uniforms; - bool bloom = frame->main && (_scene.bloomStrength > 0.0f); + bool bloom = frame->main && (_scene.bloomStrength > 0.0f) && !_scene.lowQuality; if ((_scene.postPipeline == NULL) && !_createPostPipeline()) { return; @@ -3038,6 +3047,35 @@ static int32_t _pipelineVariant(int32_t node) { } +// The pipelines nearly every scene draws with, built when the scene is enabled rather than by the +// first frame that needs them. A mobile driver compiles at link -- a Mali takes up to a second a +// program -- and a script that enables the scene and then starts its music heard it play over a +// screen that had not come up yet. Only one variant of each shader pair is built: GLES links a +// program per pair and shares it, and the other variants cost nothing when they are first drawn. +// With the backend's binary cache this is quick from the second run on. Failures are left to the +// frame that draws, which reports them as it always has. +static void _prewarmPipelines(void) { + int32_t sampleSet = (_scene.sampleCount == SDL_GPU_SAMPLECOUNT_1) ? SAMPLE_SET_SINGLE : SAMPLE_SET_MULTI; + + _scene.prewarmed = true; + if (_scene.pipelines[sampleSet][0] == NULL) { + _createPipeline(sampleSet, 0); + } + if (_scene.pipelines[sampleSet][PIPELINE_SKINNED] == NULL) { + _createPipeline(sampleSet, PIPELINE_SKINNED); + } + if (_scene.postPipeline == NULL) { + _createPostPipeline(); + } + if (!_scene.lowQuality && ((_scene.bloomDownPipeline == NULL) || (_scene.bloomUpPipeline == NULL))) { + _createBloomPipelines(); + } + if (_scene.particlePipelines[sampleSet][0] == NULL) { + _createParticlePipeline(sampleSet, 0); + } +} + + // The camera's projection for a target of the given size. static Mat4T _projectionFor(int32_t width, int32_t height) { float aspect = (height > 0) ? (float)width / (float)height : 1.0f; @@ -5381,6 +5419,9 @@ bool sceneEnable(bool enabled) { if (enabled && (_scene.device == NULL)) { return false; } + if (enabled && !_scene.prewarmed) { + _prewarmPipelines(); + } _scene.enabled = enabled; return true; } @@ -5793,7 +5834,7 @@ SDL_Texture *sceneRender(void) { } // Transforms, the window's camera and the lights for this frame. _updateWorld(SCENE_ROOT_NODE, &identity, true); - _cameraFrame(_scene.cameraNode, _scene.width, _scene.height, &main); + _cameraFrame(_scene.cameraNode, _scene.renderWidth, _scene.renderHeight, &main); main.colour = _scene.colour; main.multisampled = _scene.multisampled; main.depth = _scene.depth; @@ -5967,15 +6008,19 @@ bool sceneResize(int32_t width, int32_t height) { return true; } _destroyTargets(); - _scene.width = width; - _scene.height = height; + _scene.width = width; + _scene.height = height; + // A small GPU spends most of its frame filling pixels, so --scenequality low draws a quarter of + // them and the composite scales the picture up to the overlay like any other texture. + _scene.renderWidth = _scene.lowQuality ? SDL_max(width / LOW_QUALITY_DIVISOR, 1) : width; + _scene.renderHeight = _scene.lowQuality ? SDL_max(height / LOW_QUALITY_DIVISOR, 1) : height; // The display texture the post pass writes and the 2D renderer composites. memset(&info, 0, sizeof(info)); info.type = SDL_GPU_TEXTURETYPE_2D; info.format = rgpuGetTextureFormatFromPixelFormat(SDL_PIXELFORMAT_BGRA32); info.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER; - info.width = (Uint32)width; - info.height = (Uint32)height; + info.width = (Uint32)_scene.renderWidth; + info.height = (Uint32)_scene.renderHeight; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = SDL_GPU_SAMPLECOUNT_1; @@ -5995,7 +6040,7 @@ bool sceneResize(int32_t width, int32_t height) { // 4x multisampling when wanted and offered: a multisampled colour target resolved into colour, // and a multisampled depth target to match. _scene.sampleCount = SDL_GPU_SAMPLECOUNT_1; - if (_scene.antialias && rgpuTextureSupportsSampleCount(_scene.device, info.format, SDL_GPU_SAMPLECOUNT_4) && rgpuTextureSupportsSampleCount(_scene.device, _scene.depthFormat, SDL_GPU_SAMPLECOUNT_4)) { + if (_scene.antialias && !_scene.lowQuality && rgpuTextureSupportsSampleCount(_scene.device, info.format, SDL_GPU_SAMPLECOUNT_4) && rgpuTextureSupportsSampleCount(_scene.device, _scene.depthFormat, SDL_GPU_SAMPLECOUNT_4)) { info.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; info.sample_count = SDL_GPU_SAMPLECOUNT_4; _scene.multisampled = rgpuCreateTexture(_scene.device, &info); @@ -6018,13 +6063,18 @@ bool sceneResize(int32_t width, int32_t height) { // The camera's depth for soft particles: single sample in the shadow pipelines' format, so the // depth-only pipelines can fill it. _scene.softDepth = _createShadowArray(SDL_GPU_TEXTURETYPE_2D, 1, 0); - _scene.composite = renderWrapTexture(_scene.renderer, _scene.output, SDL_PIXELFORMAT_BGRA32, width, height); + _scene.composite = renderWrapTexture(_scene.renderer, _scene.output, SDL_PIXELFORMAT_BGRA32, _scene.renderWidth, _scene.renderHeight); if (_scene.composite == NULL) { utilTrace("Scene: %s", SDL_GetError()); _destroyTargets(); return false; } SDL_SetTextureBlendMode(_scene.composite, SDL_BLENDMODE_BLEND); + // The pipelines went with the old targets; a scene already running gets its common ones back + // now rather than at its next frame. + if (_scene.enabled) { + _prewarmPipelines(); + } return true; } @@ -6085,6 +6135,21 @@ void sceneSetFog(uint8_t r, uint8_t g, uint8_t b, float near, float far) { } +// --scenequality low: the machine's owner says the GPU is small, so the scene renders at half the +// overlay's size each way, and antialiasing and bloom stay off whatever the script asks for. The script's own settings are kept, so they come back with high. +// Takes effect on the next resize, like antialiasing, so the targets are rebuilt here. +void sceneSetLowQuality(bool low) { + int32_t width = _scene.width; + int32_t height = _scene.height; + + _scene.lowQuality = low; + if (width > 0) { + _destroyTargets(); + sceneResize(width, height); + } +} + + // Cascades for a directional light's shadow, 1 to MAX_CASCADES (1 fits one map to the whole scene). void sceneSetShadowCascades(int32_t count) { _scene.shadowCascades = SDL_clamp(count, 1, MAX_CASCADES); @@ -6217,8 +6282,11 @@ void sceneUpdateVideo(SceneVideoSourceFn source) { continue; } if (feed->target == NULL) { + // RGBA32, which is how GLES stores it and how the scene samples it. SDL's GLES2 renderer + // keeps a BGRA32 texture's bytes swapped and undoes that only when it samples one itself, + // so a BGRA video drawn into a BGRA target came through swapped and read red as blue. SDL_GetTextureSize(frame, &width, &height); - feed->target = SDL_CreateTexture(_scene.renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_TARGET, (int32_t)width, (int32_t)height); + feed->target = SDL_CreateTexture(_scene.renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_TARGET, (int32_t)width, (int32_t)height); if (feed->target == NULL) { utilTrace("Scene: video feed: %s", SDL_GetError()); continue; diff --git a/src/scene.h b/src/scene.h index 3e29bdbc9..6fb143090 100644 --- a/src/scene.h +++ b/src/scene.h @@ -107,6 +107,7 @@ void sceneSetBloom(float threshold, float strength); bool sceneSetEnvironment(bool lit); void sceneSetExposure(float stops); void sceneSetFog(uint8_t r, uint8_t g, uint8_t b, float near, float far); +void sceneSetLowQuality(bool low); void sceneSetShadowCascades(int32_t count); void sceneSetShadowDistance(float distance); void sceneSetShadowSize(int32_t size); diff --git a/src/shaders/scene.hlsl b/src/shaders/scene.hlsl index d6ae4c720..86c3d7dd0 100644 --- a/src/shaders/scene.hlsl +++ b/src/shaders/scene.hlsl @@ -58,12 +58,34 @@ cbuffer DrawUniforms : register(b0, space1) { // Morph target deltas: per target, per vertex, a position delta then a normal delta. -StructuredBuffer morphDeltas : register(t0, space0); - +// // Per draw, in draw order: the model matrix then its inverse transpose (for normals under // non-uniform scale); an instanced batch reads consecutive pairs. The inverse transpose's spare // lane (_m33) holds the draw's BILLBOARD_MODE_*; a billboard's pair is rebuilt here per camera. +// +// SINGE_ESSL is the build of the OpenGL ES forms. ES 3.1 lets a driver offer no storage blocks +// to a vertex shader at all, and ARM's Mali drivers offer none, so there the same bytes are read +// as texture buffers instead: one RGBA32F texel per float4, and a matrix as four texels holding +// its columns (HLSL's default column-major packing, the same layout the structured buffer reads). +#ifdef SINGE_ESSL +Buffer morphDeltas : register(t0, space0); +Buffer instanceMatrices : register(t1, space0); +#else +StructuredBuffer morphDeltas : register(t0, space0); StructuredBuffer instanceMatrices : register(t1, space0); +#endif + + +float4x4 instanceMatrix(uint index) { +#ifdef SINGE_ESSL + uint texel = index * 4; + + return transpose(float4x4(instanceMatrices[texel], instanceMatrices[texel + 1], instanceMatrices[texel + 2], instanceMatrices[texel + 3])); +#else + return instanceMatrices[index]; +#endif +} + cbuffer SkinUniforms : register(b1, space1) { float4x4 joints[MAX_JOINTS]; @@ -150,8 +172,8 @@ VertexOutput vertexStatic(VertexInput input, uint vertex : SV_VertexID, uint ins VertexOutput output; float3 position = input.position; float3 normal = input.normal; - float4x4 model = instanceMatrices[(morphInfo.z + instance) * 2]; - float4x4 normalMatrix = instanceMatrices[(morphInfo.z + instance) * 2 + 1]; + float4x4 model = instanceMatrix((morphInfo.z + instance) * 2); + float4x4 normalMatrix = instanceMatrix((morphInfo.z + instance) * 2 + 1); billboard(model, normalMatrix); morph(vertex, position, normal); @@ -168,8 +190,8 @@ VertexOutput vertexSkinned(VertexInput input, uint vertex : SV_VertexID, uint in VertexOutput output; float3 morphed = input.position; float3 normal = input.normal; - float4x4 model = instanceMatrices[(morphInfo.z + instance) * 2]; - float4x4 normalMatrix = instanceMatrices[(morphInfo.z + instance) * 2 + 1]; + float4x4 model = instanceMatrix((morphInfo.z + instance) * 2); + float4x4 normalMatrix = instanceMatrix((morphInfo.z + instance) * 2 + 1); float4 position; float4 skinned; float3 skinnedNormal; diff --git a/src/singe.c b/src/singe.c index a31138966..cb793844d 100644 --- a/src/singe.c +++ b/src/singe.c @@ -25,6 +25,10 @@ #include #include #include +#ifdef __linux__ +#include +#include +#endif // SHA-256 for utilSha256, from the OpenSSL already linked in for LuaSec. #include @@ -139,6 +143,9 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define IDLE_SLEEP_MS 1 #define OVERLAY_SCALE_DEFAULT 0.5 +#define OVERLAY_TEXTURES 3 // Overlay textures uploads take turns in, so none is rewritten while a frame still reads it +#define OVERLAY_VIDEOS_MAX 8 // videoDraw calls the GPU draws under the overlay; more go through the CPU +#define REGION_RECTS_MAX 16 // Separate rectangles a RegionT keeps; more are merged #define CONSOLE_FONT_GLYPHS 256 #define DEGREES_PER_CIRCLE 360.0 #define ANIMATION_MIN_DELAY_MS 10 // GIFs often carry a zero delay @@ -213,6 +220,9 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define SOUND_QUEUE_SIZE 64 #define MS_PER_SECOND_NUMBER 1000.0 #define MS_PER_SECOND 1000 // --idleexit counts in whole seconds +#define PERF_TRACE_NS (5 * SDL_NS_PER_SECOND) // How often -p says where the frame loop's time went +#define PERF_TOP_CALLS 8 // Engine calls the -p script line names, the costliest first +#define PERF_LINE_BYTES 1024 // Room for that line #define INPUT_GRACE_MS 1000 // Presses this soon after a script starts or focus arrives were held over from before #define EFFECT_TRACKS 16 // Sound effect "channels" scripts can play at once #define SOUND_DEFAULT_NEAR 1.0f // A positioned sound is at full volume within this distance ... @@ -244,6 +254,13 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define MUSIC_TAG "music" // Music carries its own gain, so musicSetVolume never moves a sound effect #define MUSIC_LOOP_MAX 64 // Hypseus clamps musicPlay's loop count to this #define HELD_KEYS_MAX 64 // Keys physically down at once +#define CHORD_CODES_MAX 4 // Buttons held together in one chord +#define INPUT_DEVICE_DIR "/dev/input" +#define INPUT_EVENT_PREFIX "event" // The input devices ManyMouse reads +#define INPUT_PATH_MAX 288 // /dev/input/ and the longest name a directory entry holds +#define QUIT_CHORD_WAIT_MS 250 // How long a quit chord button's own press waits for the rest of the chord +#define TEXT_CACHE_KEEP_MS 1000 // How long fontPrint keeps a string it has stopped printing +#define TEXT_CACHE_KEY_HEADER 48 // Room in a text cache key for everything ahead of the text #define PAUSE_TEXT "PAUSED" #define PAUSE_TEXT_SCALE 3 // Console font is small; scale the indicator up #define QUAD_VERTICES 4 // A textured quad for SDL_RenderGeometry ... @@ -495,6 +512,27 @@ typedef struct FontS { UT_hash_handle hh; } FontT; +// A string fontPrint has already rendered, ready to blit: SDL_ttf, the unpacking and the colour key +// are the costly part of printing, and a game that reprints its HUD every frame asks for the same +// strings over and over. No fixed number of them: a high score table prints dozens a frame, and a +// cache smaller than that misses every time. Strings not printed for TEXT_CACHE_KEEP_MS go instead. +typedef struct TextCacheS { + char *key; // Font, quality, colours and text: everything that changes the picture + SDL_Surface *surface; + int32_t font; // Dropped with it + uint64_t usedMS; // Last printed, by utilTicks + UT_hash_handle hh; +} TextCacheT; + +// Part of the overlay: a few rectangles, or all of it. What was drawn since overlayClear, what +// changed since the last upload, and what each of the overlay's textures has yet to be sent. More +// rectangles than it keeps are merged, so a region only ever errs on the large side. +typedef struct RegionS { + SDL_Rect rect[REGION_RECTS_MAX]; + int32_t count; + bool all; +} RegionT; + typedef struct VideoS { int32_t id; int32_t handle; @@ -510,9 +548,20 @@ typedef struct VideoS { UT_hash_handle hh; } VideoT; +// Buttons held together for one switch: controls.cfg's nested lists, such as Select and Start for quit +// on a handheld with no Escape key. It fires on the press that completes it and lets go when any of +// its buttons does. Each button keeps its own meaning as well. +typedef struct ChordS { + int32_t code[CHORD_CODES_MAX]; + int32_t count; + bool active; +} ChordT; + typedef struct MappingS { int32_t inputCount; int32_t *input; + int32_t chordCount; + ChordT *chord; } MappingT; // One guiDraw this frame: a GUI composited over the overlay, in overlay coordinates or covering @@ -535,6 +584,13 @@ typedef struct OverlayLayerS { bool dirty; } OverlayLayerT; +// A videoDraw the GPU does instead: the frame's texture, drawn under the base overlay where the +// overlay is still transparent, which looks the same as the frame copied into it. +typedef struct OverlayVideoS { + SDL_Texture *texture; + SDL_Rect dest; // Overlay coordinates +} OverlayVideoT; + typedef struct GlobalS { MouseT mice[MAX_MICE]; lua_State *luaContext; @@ -545,7 +601,9 @@ typedef struct GlobalS { OverlayLayerT overlayLayers[GUI_DRAW_MAX]; // The layers this turn's guiDraw calls opened, in order int32_t overlayLayerCount; SDL_Surface *overlayGrey; // The overlay in luma only, for overlaySetMonochrome - SDL_Texture *overlayTexture; + SDL_Texture *overlayTexture; // The one holding the current overlay, one of overlayTextures + SDL_Texture *overlayTextures[OVERLAY_TEXTURES]; + int32_t overlayTextureIndex; SDL_FRect focusArea; // vldpFocusArea's part of the disc frame, in disc pixels bool focused; bool overlayMonochrome; @@ -671,9 +729,25 @@ typedef struct GlobalS { int32_t discLumaLevel; uint8_t overlayOpacity; // setOverlayOpacity, over the whole overlay bool overlayDirty; + RegionT overlayPainted; // Drawn on the base overlay since overlayClear; the rest is overlayClearColor + uint32_t overlayClearColor; + uint8_t overlayClearAlpha; // Of overlayClearColor: videos go under the overlay only where it is see-through + OverlayVideoT overlayVideos[OVERLAY_VIDEOS_MAX]; // Under the base overlay until the next overlayClear + int32_t overlayVideoCount; + RegionT overlayChanged; // Changed on the base overlay since it was last uploaded + RegionT overlayStale[OVERLAY_TEXTURES]; // What each texture is behind the base overlay by bool pauseState; // by RDG2010 bool pauseEnabled; // by RDG2010 bool quitEnabled; // The engine quits on the quit switch + ChordT quitChord; // --quitchord: the machine's own way out, whatever the game maps + TextCacheT *textCache; + char *textCacheKey; // Scratch for building a key to look up + size_t textCacheKeyBytes; + uint64_t textCacheSweepMS; // When unused strings were last dropped + int32_t quitChordRelease; // The button that completed it, whose release the script is not told of either, or -1 + int32_t quitChordHeld[CHORD_CODES_MAX]; // Its buttons pressed while it waits to see whether the rest follow + int32_t quitChordHeldCount; + uint64_t quitChordHeldMS; // When the first of them was pressed, by utilTicks bool refreshDisplay; bool running; bool discStopped; @@ -702,8 +776,49 @@ typedef struct GlobalS { ConfigT *conf; // Local copy of command line options } GlobalT; +// -p's account of the frame loop: where the time went, summed until PERF_TRACE_NS has passed and +// then traced as averages per frame. Only the stages that can plausibly be slow are timed; "other" +// is the remainder, input and the 2D layers mostly. +typedef struct PerfS { + uint64_t windowStartNS; + uint64_t scriptStartNS; + uint64_t loops; + uint64_t presents; + uint64_t discNS; + uint64_t scriptNS; + uint64_t sceneNS; + uint64_t guiNS; + uint64_t overlayNS; + uint64_t composeNS; // --perfsync: SDL_Renderer's own drawing, finished on the GPU before the swap + uint64_t presentNS; + uint64_t totalNS; + uint64_t worstNS; + bool firstPresented; +} PerfT; + + +// -p's account of the script's time: every engine call a script can make, wrapped while tracing so +// its time and count are kept, which splits the script's share of the loop into the engine's work +// on its behalf and the Lua around it. Kept apart from PerfT, because the calls are wrapped when a +// script's Lua state is made and PerfT is cleared after that. +typedef struct PerfCallS { + char *name; + uint64_t ns; + uint64_t calls; +} PerfCallT; + +typedef struct PerfCallsS { + PerfCallT *call; + int32_t count; + uint64_t ns; // In every wrapped call, wherever it was made + uint64_t scriptNS; // Of that, inside onOverlayUpdate, the loop's "script" stage + bool inScript; +} PerfCallsT; + static GlobalT _global; +static PerfT _perf; +static PerfCallsT _perfCalls; static MIX_Track *_effectTracks[EFFECT_TRACKS]; static EffectT _effects[EFFECT_TRACKS]; @@ -740,6 +855,7 @@ static const InputNameT _inputNames[INPUT_COUNT] = { static const int32_t _sdlMouseButtonToCode[] = { 0, 0, 2, 1, 3, 4 }; +static void _actOnKey(bool down, int32_t keysym, int32_t scancode); static int32_t _apiUnimplemented(lua_State *L, const char *method); static int32_t _argAnimation(lua_State *L, const char *method, int32_t model, int32_t index); static int32_t _argAnimationLayer(lua_State *L, const char *method, int32_t index); @@ -784,6 +900,8 @@ static void _bezelLoad(void); static bool _bezelReadCutout(const char *path, int32_t width, int32_t height, SDL_FRect *cutout); static ConfigT *_buildConfFromTable(lua_State *L, const ConfigT *base, const char *method); static void _callLua(const char *func, const char *sig, ...); +static bool _chordChanged(ChordT *chord, bool down, int32_t scancode); +static bool _chordsUpdate(bool down, int32_t scancode); static bool _clipLine(int32_t *x1, int32_t *y1, int32_t *x2, int32_t *y2); static void _computeVideoRect(void); static void _confFail(lua_State *L, const char *method, const char *fmt, ...) __attribute__((format(printf, 3, 4))) __attribute__((noreturn)); @@ -812,6 +930,7 @@ static float _effectGain(const EffectT *effect, float distance); static void _effectReset(int32_t channel); static void _effectStopped(void *userdata, MIX_Track *track); static int32_t _effectTrackFree(void); +static void _engineAction(InputE engine); static InputE _engineSwitch(int32_t scancode); static void _fireMouseMoved(int32_t device, int32_t x, int32_t y, int32_t xr, int32_t yr); static void _fitRect(int32_t width, int32_t height, int32_t spaceWidth, int32_t spaceHeight, int32_t margin, SDL_FRect *rect); @@ -832,9 +951,11 @@ static bool _guiSwitch(InputE move, bool down); static bool _guiText(const char *text); static void _guiTextInput(void); static void _heldListUpdate(HeldKeyT *list, int32_t *count, bool down, int32_t keysym, int32_t scancode); +static bool _inputDevicesDenied(void); static void _installFileHooks(lua_State *L); static bool _isInputEvent(uint32_t type); static void _joyMouseUpdate(void); +static void _joystickButtonTrace(const SDL_JoyButtonEvent *event); static int32_t _lfsAttributes(lua_State *L); static int32_t _lfsDir(lua_State *L); static int32_t _lfsDirIterator(lua_State *L); @@ -904,18 +1025,29 @@ static void _navCallbacks(void); static void _noteInput(void); static void _overlayApplyOpacity(void); static void _overlayApplyScaleMode(void); +static SDL_Rect _overlayArea(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t margin); static uint32_t _overlayColor(const SDL_Color *color); static SDL_Surface *_overlayGrey(void); static void _overlayLayerDraw(int32_t index, const SDL_FRect *target); static int32_t _overlayLayerOpen(void); static void _overlayLayersFree(void); static void _overlayResize(int32_t width, int32_t height); -static void _overlayTouched(void); +static void _overlayTexturesDestroy(void); +static void _overlayTouched(const SDL_Rect *area); +static void _overlayUpload(SDL_Texture *texture, SDL_Surface *surface, const RegionT *region); +static bool _overlayVideoUnder(SDL_Texture *texture, const SDL_Rect *dest); +static void _overlayVideosDraw(const SDL_FRect *target); static void _particleTexturesDestroy(ParticleTexturesT *cache); static void _particleTexturesFree(int32_t emitter); static void _particleTexturesFreeAll(void); static ParticleTexturesT *_particleTexturesGet(const EmitterViewT *view); static void _pauseAllVideos(bool pause); +static int _perfCall(lua_State *L); // lua_CFunction. Not changing int. +static int _perfCompare(const void *a, const void *b); // qsort callback. Not changing int. +static void _perfSync(void); +static void _perfTrace(uint64_t loopNS); +static void _perfWrapApi(lua_State *L); +static bool _physicallyHeld(int32_t scancode); static void _physicsCallbacks(void); static void _presentSize(int32_t *width, int32_t *height); static void _processKey(bool down, int32_t keysym, int32_t scancode); @@ -925,11 +1057,17 @@ static int32_t _pushVec3(lua_State *L, Vec3T v); static void _putPixel(int32_t x, int32_t y, uint32_t pixel); static void _quadIndex(int32_t *indices, int32_t slot, int32_t quad); static void _quadScratch(int32_t quads, int32_t frames); +static bool _quitChordHold(int32_t scancode); +static void _quitChordLetGo(int32_t scancode); static void _readColor(lua_State *L, const char *method, SDL_Color *color, uint8_t defaultAlpha); +static void _regionAdd(RegionT *region, const SDL_Rect *area); +static void _regionAddRegion(RegionT *region, const RegionT *other); +static void _regionClear(RegionT *region); static void _registerApi(lua_State *L); static void _releaseAxis(int32_t axisIndex); static void _reloadScript(void); static SDL_Surface *_renderText(lua_State *L, const char *method, const char *message); +static SDL_Surface *_renderTextCached(lua_State *L, const char *method, const char *message); static void _resetScriptState(void); static void _rmlEscape(char *out, size_t size, const char *text); static void _runScript(bool fatal); @@ -995,6 +1133,8 @@ static void _suppressHeldInput(void); static uint32_t _surfaceCornerKey(SDL_Surface *surface); static void _surfaceUnpack(SDL_Surface **surface); static void _takeScreenshot(void); +static void _textCacheDrop(int32_t font); +static void _textCacheRemove(TextCacheT *entry); static void _toggleFullScreen(void); static void _unloadScriptResources(void); static void _unrotateDelta(float dx, float dy, float *vx, float *vy); @@ -1132,6 +1272,7 @@ static int32_t apiGuiHide(lua_State *L); static int32_t apiGuiLoad(lua_State *L); static int32_t apiGuiLoadFont(lua_State *L); static int32_t apiGuiNew(lua_State *L); +static int32_t apiGuiRefresh(lua_State *L); static int32_t apiGuiSetHandler(lua_State *L); static int32_t apiGuiSetInput(lua_State *L); static int32_t apiGuiSetValue(lua_State *L); @@ -1590,6 +1731,42 @@ static const LuaModuleT _luaModules[] = { // ===== Internal helpers ===== +// What a press or a release does once it counts: the engine's own switches, the chords, and the +// script. _processKey records it first, and may hold a press back for the quit chord. +static void _actOnKey(bool down, int32_t keysym, int32_t scancode) { + InputE engine = INPUT_COUNT; + bool keyboard = (scancode < CODE_GAMEPAD_BASE); // Every defined scancode is below the gamepad range + + // Engine owned switches act on the press. Keyboard mappings only count in MODE_NORMAL, so + // full mode keeps every key for the game; gamepad and mouse buttons cannot be typed, so they + // always count. + if ((_global.keyboardMode == KEYBOARD_NORMAL) || !keyboard) { + engine = _engineSwitch(scancode); + } + if (down && _global.conf->reload && (scancode == SDL_SCANCODE_F5)) { + _global.reloadRequested = true; + } + if (down) { + _engineAction(engine); + } + // Chords, under the same rule: in full mode the keyboard belongs to the game. The press that + // completes the machine's quit chord, and later its release, go no further. + if (((_global.keyboardMode == KEYBOARD_NORMAL) || !keyboard) && _chordsUpdate(down, scancode)) { + return; + } + if (!down && (scancode == _global.quitChordRelease)) { + _global.quitChordRelease = -1; + return; + } + + // The script never sees the pause key while the engine owns it, or anything while frozen. + if (_global.frozen || ((engine == INPUT_PAUSE) && _global.pauseEnabled)) { + return; + } + _deliverKey(down, keysym, scancode); +} + + // Legacy Daphne functions that Singe never implemented. They trace and return nothing. static int32_t _apiUnimplemented(lua_State *L, const char *method) { _luaTrace(L, method, "Unimplemented"); @@ -2386,6 +2563,63 @@ static void _callLua(const char *func, const char *sig, ...) { } +// Whether a press or a release changes a chord: a press that completes it, or the release of one of +// its buttons while it is down. The chord is updated to match. +static bool _chordChanged(ChordT *chord, bool down, int32_t scancode) { + bool member = false; + bool complete = true; + int32_t c = 0; + + for (c = 0; c < chord->count; c++) { + member = member || (chord->code[c] == scancode); + complete = complete && _physicallyHeld(chord->code[c]); + } + if (!member || (chord->active == down) || (down && !complete)) { + return false; + } + chord->active = down; + + return true; +} + + +// Chords after a press or a release. A press fires any chord it completes; a release lets go of any +// fired chord it belongs to. Firing does what a single button mapped to the same switch does: the +// engine's own action for pause, quit, screenshot and grab, and the switch handed to the script +// unless the engine keeps it, as it keeps pause. The machine's quit chord goes last, and quits even a +// game that turned its quit key off: a handheld with no keyboard has no other way out. It ends this +// script as any quit does, so a game started from the menu goes back to the menu. True when the press +// completed it: that press is the machine's, and the script is not told of it -- in the menu, the +// Start that completes Select and Start would otherwise also start the game under the cursor. +static bool _chordsUpdate(bool down, int32_t scancode) { + int32_t move = 0; + int32_t x = 0; + + for (move = 0; move < INPUT_COUNT; move++) { + for (x = 0; x < _global.controlMappings[move].chordCount; x++) { + if (!_chordChanged(&_global.controlMappings[move].chord[x], down, scancode)) { + continue; + } + if (down) { + _engineAction((InputE)move); + } + if ((move != INPUT_PAUSE) || !_global.pauseEnabled) { + _deliverSwitch((InputE)move, down); + } + } + } + if ((_global.quitChord.count > 0) && _chordChanged(&_global.quitChord, down, scancode) && down) { + _progTrace("Quit chord pressed"); + _global.running = false; + _global.quitChordRelease = scancode; + _global.quitChordHeldCount = 0; + return true; + } + + return false; +} + + // Liang-Barsky clip of a line to the overlay. False when none of it is on the overlay; otherwise // the ends are moved onto it. static bool _clipLine(int32_t *x1, int32_t *y1, int32_t *x2, int32_t *y2) { @@ -2539,19 +2773,21 @@ static void _confFail(lua_State *L, const char *method, const char *fmt, ...) { // Maps an SDL joystick instance ID to our controller slot, or -1. -// The four places a user configuration file is read from, in the order they are applied: the -// working directory, above the data directory, inside it, and beside the game's script. A place -// that repeats an earlier one comes back NULL, because without -d the data directory is the -// script's own and the same file would otherwise be read twice. The caller frees the strings. +// The four places a configuration file is read from, in the order they are applied, so a later one +// wins: beside the game's script, the game author's defaults; the working directory, the +// machine's; above the data directory; and inside it, the player's own for that one game. A +// place that repeats an earlier one comes back NULL, because without -d the data directory is the +// script's own and the same file would otherwise be read twice; it then counts as the game's. The +// caller frees the strings. static void _configPlaces(const char *file, const char *dataDir, const char *scriptFile, char **places) { char *scriptDir = utilGetUpToLastPathComponent(scriptFile); int32_t c = 0; int32_t x = 0; - places[0] = strdup(file); - places[1] = utilCreateString("%s..%c%s", dataDir, utilGetPathSeparator(), file); - places[2] = utilCreateString("%s%s", dataDir, file); - places[3] = utilCreateString("%s%s", scriptDir, file); + places[0] = utilCreateString("%s%s", scriptDir, file); + places[1] = strdup(file); + places[2] = utilCreateString("%s..%c%s", dataDir, utilGetPathSeparator(), file); + places[3] = utilCreateString("%s%s", dataDir, file); free(scriptDir); for (c = 0; c < SETTINGS_PLACES; c++) { for (x = 0; x < c; x++) { @@ -2580,10 +2816,31 @@ static int32_t _controllerSlot(SDL_JoystickID which) { // A fresh Lua state with the standard libraries, the vfs hooks, the constants and the whole API. static void _createScriptContext(void) { + lua_State *L = NULL; + _progTrace("Creating Lua context for script"); - _global.luaContext = luaL_newstate(); - _startLuaContext(_global.luaContext); - _registerApi(_global.luaContext); + L = luaL_newstate(); + _global.luaContext = L; + _startLuaContext(L); + // -p times the engine calls: what was there before the API is noted, so only the API is wrapped. + if (_global.conf->programTracing) { + lua_newtable(L); + lua_pushglobaltable(L); + lua_pushnil(L); + while (lua_next(L, -2)) { + lua_pop(L, 1); + lua_pushvalue(L, -1); + lua_pushboolean(L, 1); + lua_rawset(L, -5); + } + lua_pop(L, 1); + } + _registerApi(L); + if (_global.conf->programTracing) { + _perfWrapApi(L); + lua_pop(L, 1); + _progTrace("Timing %d engine calls for the perf lines", _perfCalls.count); + } } @@ -2608,8 +2865,11 @@ static bool _delayAndPump(uint32_t ms) { _global.running = false; } } + if (mainTerminated()) { + _global.running = false; + } SDL_Delay(IDLE_SLEEP_MS); - } while (SDL_GetTicks() < until); + } while (_global.running && (SDL_GetTicks() < until)); return _global.running; } @@ -3204,6 +3464,38 @@ static int32_t _effectTrackFree(void) { } +// What the engine does itself for the switches it owns, on the press, whether it came from a button +// or a chord. Anything else is the script's alone. +static void _engineAction(InputE engine) { + switch (engine) { + case INPUT_PAUSE: + if (_global.pauseEnabled) { + _setPause(!_global.pauseState, true); + } + break; + + case INPUT_GRAB: + _setMouseCaptured(!_global.mouseGrabbed); + break; + + case INPUT_QUIT: + if (_global.quitEnabled) { + _global.running = false; + } + break; + + case INPUT_SCREENSHOT: + // Force a redraw so the shot is taken now, even while paused. + _global.requestScreenShot = true; + _global.refreshDisplay = true; + break; + + default: + break; + } +} + + // Which engine owned switch (pause, quit, screenshot, grab) a code is mapped to, or INPUT_COUNT. static InputE _engineSwitch(int32_t scancode) { static const InputE owned[] = { INPUT_PAUSE, INPUT_QUIT, INPUT_SCREENSHOT, INPUT_GRAB }; @@ -3247,6 +3539,7 @@ static void _fontDestroy(FontT *font) { if (_global.fontCurrent == font) { _global.fontCurrent = NULL; } + _textCacheDrop(font->id); HASH_DEL(_global.fontList, font); TTF_CloseFont(font->font); free(font); @@ -3548,7 +3841,8 @@ static bool _guiPointer(int32_t x, int32_t y, int32_t *gui, int32_t *guiX, int32 // guiDraw and guiDrawScreen queue the same record; only the rectangle it lands in differs. static int32_t _guiQueueDraw(lua_State *L, const char *method, bool screen) { GuiDrawT *draw; - int32_t top = lua_gettop(L); + int32_t top = lua_gettop(L); + SDL_Rect nothing = { 0, 0, 0, 0 }; _argCheck(L, method, 1, 5); if ((top == 2) || (top == 4)) { @@ -3569,7 +3863,7 @@ static int32_t _guiQueueDraw(lua_State *L, const char *method, bool screen) { } draw->layer = screen ? OVERLAY_NO_LAYER : _overlayLayerOpen(); _global.guiDrawCount++; - _overlayTouched(); + _overlayTouched(¬hing); return 0; } @@ -3677,21 +3971,49 @@ static void _heldListUpdate(HeldKeyT *list, int32_t *count, bool down, int32_t k } +// Whether this user is refused the input devices: they are there but cannot be read, which is what +// an account outside the "input" group sees. ManyMouse finding no mice means only that on a machine +// that has none, a handheld for one, so that is no reason to mention the group. +static bool _inputDevicesDenied(void) { +#ifdef __linux__ + DIR *dir = opendir(INPUT_DEVICE_DIR); + struct dirent *entry = NULL; + char path[INPUT_PATH_MAX]; + bool denied = false; + + if (dir == NULL) { + return false; + } + while (!denied && ((entry = readdir(dir)) != NULL)) { + if (strncmp(entry->d_name, INPUT_EVENT_PREFIX, sizeof(INPUT_EVENT_PREFIX) - 1) == 0) { + snprintf(path, sizeof(path), "%s/%s", INPUT_DEVICE_DIR, entry->d_name); + denied = (access(path, R_OK) != 0) && (errno == EACCES); + } + } + closedir(dir); + + return denied; +#else + return false; +#endif +} + + // Replaces dofile, loadfile, and the io functions that take a file name with versions that resolve // the name through the vfs. Each closure keeps the original as its first upvalue. static void _installFileHooks(lua_State *L) { + // The calls that take a file name, to find it inside the .game. static const struct { const char *name; IoHookModeE mode; } ioHooks[] = { - { "close", IO_HOOK_OPEN }, // Only the handle matters to these three; the mode is unused. { "input", IO_HOOK_INPUT }, { "lines", IO_HOOK_LINES }, { "open", IO_HOOK_OPEN }, - { "output", IO_HOOK_WRITE }, - { "read", IO_HOOK_OPEN }, - { "type", IO_HOOK_OPEN } + { "output", IO_HOOK_WRITE } }; + // The calls that take a handle, or none, and may be handed one of ours. They are not name hooks + // as well: read's string is a format, and a name hook took "*line" for a file to find. static const struct { const char *name; lua_CFunction hook; @@ -3791,6 +4113,29 @@ static void _joyMouseUpdate(void) { } +// -p: a button of a pad that its gamepad mapping leaves out never reaches a game or --quitchord, and +// nothing else would say so. Its raw number is traced, so a gamecontrollerdb.txt line can name it. +static void _joystickButtonTrace(const SDL_JoyButtonEvent *event) { + SDL_Gamepad *pad = SDL_GetGamepadFromID(event->which); + SDL_GamepadBinding **bindings = NULL; + int32_t count = 0; + int32_t x = 0; + bool mapped = false; + + if (!_global.conf->programTracing || (pad == NULL)) { + return; + } + bindings = SDL_GetGamepadBindings(pad, &count); + for (x = 0; (bindings != NULL) && (x < count) && !mapped; x++) { + mapped = (bindings[x]->input_type == SDL_GAMEPAD_BINDTYPE_BUTTON) && (bindings[x]->input.button == event->button); + } + SDL_free(bindings); + if (!mapped) { + _progTrace("Pad %d: button b%d %s is not in its mapping; a gamecontrollerdb.txt line can name it", _controllerSlot(event->which), event->button, event->down ? "down" : "up"); + } +} + + // lfs.attributes through the vfs, with lfs's own forms: attributes(path) makes a table, // attributes(path, name) answers one attribute (an unknown name raises), attributes(path, table) // fills the table. A packed entry answers as a read only file; a plain filesystem path goes to @@ -3969,6 +4314,7 @@ static int32_t _lfsRmdir(lua_State *L) { // the framework but not the API. Leaves the dead zone and the switch mappings behind. static void _loadControlMappings(void) { lua_State *L = NULL; + ChordT *chord = NULL; char *places[SETTINGS_PLACES]; double threshold = 0.0; int32_t code = 0; @@ -4078,16 +4424,35 @@ static void _loadControlMappings(void) { } y = (int32_t)lua_rawlen(L, -1); _global.controlMappings[x].input = (int32_t *)calloc((size_t)(y + 1), sizeof(int32_t)); - if (!_global.controlMappings[x].input) { + _global.controlMappings[x].chord = (ChordT *)calloc((size_t)(y + 1), sizeof(ChordT)); + if (!_global.controlMappings[x].input || !_global.controlMappings[x].chord) { utilDie("Unable to allocate memory for control mappings."); } _global.controlMappings[x].inputCount = 0; + _global.controlMappings[x].chordCount = 0; lua_pushnil(L); while (lua_next(L, -2)) { if (lua_istable(L, -1)) { lua_getfield(L, -1, "value"); if (lua_isnumber(L, -1) && (_global.controlMappings[x].inputCount < y)) { _global.controlMappings[x].input[_global.controlMappings[x].inputCount++] = (int32_t)lua_tonumber(L, -1); + } else if (!lua_isnumber(L, -1) && (lua_rawlen(L, -2) > 1) && (_global.controlMappings[x].chordCount < y)) { + // No value of its own but entries inside: a chord, { GAMEPAD_0.BUTTON_BACK, GAMEPAD_0.BUTTON_START }. + chord = &_global.controlMappings[x].chord[_global.controlMappings[x].chordCount]; + chord->count = 0; + for (c = 1; c <= (int32_t)lua_rawlen(L, -2); c++) { + lua_rawgeti(L, -2, c); + lua_getfield(L, -1, "value"); + if (lua_isnumber(L, -1) && (chord->count < CHORD_CODES_MAX)) { + chord->code[chord->count++] = (int32_t)lua_tonumber(L, -1); + } + lua_pop(L, 2); + } + if (chord->count > 1) { + _global.controlMappings[x].chordCount++; + } else { + utilSay("Warning: a chord in %s needs two to %d buttons.", _inputNames[x].configName, CHORD_CODES_MAX); + } } lua_pop(L, 1); } @@ -4137,8 +4502,8 @@ static SDL_Texture *_loadEmbeddedTexture(const uint8_t *data, size_t length, SDL // SDL knows a great many pads already; a gamecontrollerdb.txt adds the rest. It is looked for in -// the four places controls.cfg is looked for and every one found is added, so a file beside the -// game wins over a machine wide one. +// the four places controls.cfg is looked for and every one found is added in that order, so the +// machine's file wins over the game's, and a player's file in the game's data directory over both. static void _loadGamepadDatabase(void) { char *places[SETTINGS_PLACES]; char *data = NULL; @@ -5199,13 +5564,35 @@ static void _noteInput(void) { // Applies setOverlayOpacity's level to the overlay texture. Called again whenever the texture is made. static void _overlayApplyOpacity(void) { - SDL_SetTextureAlphaMod(_global.overlayTexture, _global.overlayOpacity); + int32_t x = 0; + + for (x = 0; x < OVERLAY_TEXTURES; x++) { + SDL_SetTextureAlphaMod(_global.overlayTextures[x], _global.overlayOpacity); + } } // Applies setOverlayLinearScale's choice to the overlay texture. Called again whenever the texture is made. static void _overlayApplyScaleMode(void) { - SDL_SetTextureScaleMode(_global.overlayTexture, _global.overlayLinearScale ? SDL_SCALEMODE_LINEAR : SDL_SCALEMODE_NEAREST); + int32_t x = 0; + + for (x = 0; x < OVERLAY_TEXTURES; x++) { + SDL_SetTextureScaleMode(_global.overlayTextures[x], _global.overlayLinearScale ? SDL_SCALEMODE_LINEAR : SDL_SCALEMODE_NEAREST); + } +} + + +// The rectangle spanning two corners, either way round, grown by margin on every side: where a +// shape between them can have drawn. +static SDL_Rect _overlayArea(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t margin) { + SDL_Rect area; + + area.x = SDL_min(x1, x2) - margin; + area.y = SDL_min(y1, y2) - margin; + area.w = abs(x2 - x1) + 1 + margin * 2; + area.h = abs(y2 - y1) + 1 + margin * 2; + + return area; } @@ -5305,6 +5692,8 @@ static void _overlayLayersFree(void) { // Replaces the overlay surface and texture (and the scene's targets) at a new size; its contents are lost. static void _overlayResize(int32_t width, int32_t height) { + int32_t x = 0; + SDL_DestroySurface(_global.overlayGrey); _global.overlayGrey = NULL; _overlayLayersFree(); @@ -5313,34 +5702,130 @@ static void _overlayResize(int32_t width, int32_t height) { if (_global.overlayBase == NULL) { utilDie("%s", SDL_GetError()); } - _global.overlay = _global.overlayBase; + _global.overlay = _global.overlayBase; + _global.overlayVideoCount = 0; SDL_SetSurfaceBlendMode(_global.overlay, SDL_BLENDMODE_BLEND); - SDL_DestroyTexture(_global.overlayTexture); - _global.overlayTexture = SDL_CreateTexture(_global.renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, width, height); - if (_global.overlayTexture == NULL) { - utilDie("%s", SDL_GetError()); + _overlayTexturesDestroy(); + for (x = 0; x < OVERLAY_TEXTURES; x++) { + _global.overlayTextures[x] = SDL_CreateTexture(_global.renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, width, height); + if (_global.overlayTextures[x] == NULL) { + utilDie("%s", SDL_GetError()); + } + SDL_SetTextureBlendMode(_global.overlayTextures[x], SDL_BLENDMODE_BLEND); } + _global.overlayTextureIndex = 0; + _global.overlayTexture = _global.overlayTextures[0]; sceneResize(width, height); - SDL_SetTextureBlendMode(_global.overlayTexture, SDL_BLENDMODE_BLEND); _overlayApplyScaleMode(); _overlayApplyOpacity(); _global.overlayScaleX = (double)width / (double)_global.canvasWidth; _global.overlayScaleY = (double)height / (double)_global.canvasHeight; - _overlayTouched(); + _overlayTouched(NULL); +} + + +// The overlay's textures, all of them, with the current one. +static void _overlayTexturesDestroy(void) { + int32_t x = 0; + + for (x = 0; x < OVERLAY_TEXTURES; x++) { + SDL_DestroyTexture(_global.overlayTextures[x]); + _global.overlayTextures[x] = NULL; + } + _global.overlayTexture = NULL; } // Every overlay drawing call ends here so a texture is only re-uploaded when needed: the base's, -// or the open layer's. -static void _overlayTouched(void) { - if (_global.overlay == _global.overlayBase) { - _global.overlayDirty = true; - } else { +// or the open layer's. area is where it drew, so overlayClear refills and the upload sends only +// that: NULL for anywhere, and an empty one for a change that drew nothing but is to be shown. +static void _overlayTouched(const SDL_Rect *area) { + SDL_Rect bounds = { 0, 0, _global.overlayBase->w, _global.overlayBase->h }; + SDL_Rect clipped; + + if (_global.overlay != _global.overlayBase) { _global.overlayLayers[_global.overlayLayerCount - 1].dirty = true; + return; + } + _global.overlayDirty = true; + if (area == NULL) { + _regionAdd(&_global.overlayPainted, NULL); + _regionAdd(&_global.overlayChanged, NULL); + return; + } + if (SDL_GetRectIntersection(area, &bounds, &clipped)) { + _regionAdd(&_global.overlayPainted, &clipped); + _regionAdd(&_global.overlayChanged, &clipped); } } +// Sends a region of the overlay to one of its textures. +static void _overlayUpload(SDL_Texture *texture, SDL_Surface *surface, const RegionT *region) { + const SDL_Rect *area = NULL; + int32_t x = 0; + + if (region->all) { + SDL_UpdateTexture(texture, NULL, surface->pixels, surface->pitch); + return; + } + for (x = 0; x < region->count; x++) { + area = ®ion->rect[x]; + SDL_UpdateTexture(texture, area, (const uint8_t *)surface->pixels + (size_t)area->y * (size_t)surface->pitch + (size_t)area->x * sizeof(uint32_t), surface->pitch); + } +} + + +// The videos under the base overlay, in the order they were drawn, scaled and faded as the overlay is. +static void _overlayVideosDraw(const SDL_FRect *target) { + float scaleX = target->w / (float)_global.overlayBase->w; + float scaleY = target->h / (float)_global.overlayBase->h; + OverlayVideoT *video = NULL; + SDL_FRect dest; + int32_t x = 0; + + for (x = 0; x < _global.overlayVideoCount; x++) { + video = &_global.overlayVideos[x]; + dest.x = target->x + (float)video->dest.x * scaleX; + dest.y = target->y + (float)video->dest.y * scaleY; + dest.w = (float)video->dest.w * scaleX; + dest.h = (float)video->dest.h * scaleY; + SDL_SetTextureScaleMode(video->texture, _global.overlayLinearScale ? SDL_SCALEMODE_LINEAR : SDL_SCALEMODE_NEAREST); + SDL_SetTextureAlphaMod(video->texture, _global.overlayOpacity); + SDL_RenderTexture(_global.renderer, video->texture, NULL, &dest); + // The scene may show the same texture as a material, at full strength. + SDL_SetTextureAlphaMod(video->texture, COLOR_BYTE_MAX); + } +} + +// Takes a videoDraw for the GPU when that looks the same as the CPU copying the frame in: onto the +// base overlay, where nothing has been drawn since a clear to a see-through colour, in full colour. +// Anything drawn afterwards goes over it, as it would over the copied frame. False leaves the +// draw to the CPU. +static bool _overlayVideoUnder(SDL_Texture *texture, const SDL_Rect *dest) { + SDL_Rect bounds = { 0, 0, _global.overlayBase->w, _global.overlayBase->h }; + SDL_Rect area; + int32_t x = 0; + + if ((texture == NULL) || (_global.overlay != _global.overlayBase) || _global.overlayPainted.all || (_global.overlayClearAlpha != 0) || _global.overlayMonochrome || (_global.overlayVideoCount >= OVERLAY_VIDEOS_MAX)) { + return false; + } + if (!SDL_GetRectIntersection(dest, &bounds, &area)) { + return false; + } + for (x = 0; x < _global.overlayPainted.count; x++) { + if (SDL_HasRectIntersection(&area, &_global.overlayPainted.rect[x])) { + return false; + } + } + _global.overlayVideos[_global.overlayVideoCount].texture = texture; + _global.overlayVideos[_global.overlayVideoCount].dest = *dest; + _global.overlayVideoCount++; + _global.refreshDisplay = true; + + return true; +} + // Frees the renderer textures kept for an emitter's 2D drawing. static void _particleTexturesDestroy(ParticleTexturesT *cache) { int32_t f = 0; @@ -5434,6 +5919,165 @@ static void _pauseAllVideos(bool pause) { } +// Stands in for an engine call while -p traces, timing the real one. Its upvalues are the call and +// its slot in _perfCalls. A call that raises a Lua error leaves without being counted, which only +// ever loses the call that failed. +static int _perfCall(lua_State *L) { + lua_CFunction real = lua_tocfunction(L, lua_upvalueindex(1)); + int32_t slot = (int32_t)lua_tointeger(L, lua_upvalueindex(2)); + uint64_t startNS = SDL_GetTicksNS(); + int results = real(L); + uint64_t spentNS = SDL_GetTicksNS() - startNS; + + _perfCalls.call[slot].ns += spentNS; + _perfCalls.call[slot].calls++; + _perfCalls.ns += spentNS; + if (_perfCalls.inScript) { + _perfCalls.scriptNS += spentNS; + } + + return results; +} + + +// Costliest first. +static int _perfCompare(const void *a, const void *b) { + const PerfCallT *left = (const PerfCallT *)a; + const PerfCallT *right = (const PerfCallT *)b; + + return (left->ns < right->ns) - (left->ns > right->ns); +} + + +// --perfsync: the GPU finishes what this stage gave it before the stage's time is taken. +static void _perfSync(void) { + if (_global.conf->perfSync) { + renderFinish(_global.device); + } +} + + +// One loop's worth into -p's account, and the account traced and restarted once PERF_TRACE_NS has +// passed. The stage timings were added in as the loop went. +static void _perfTrace(uint64_t loopNS) { + uint64_t now = SDL_GetTicksNS(); + double perLoop = 0.0; + + _perf.loops++; + _perf.totalNS += loopNS; + if (loopNS > _perf.worstNS) { + _perf.worstNS = loopNS; + } + if ((now - _perf.windowStartNS) < PERF_TRACE_NS) { + return; + } + perLoop = (double)SDL_NS_PER_MS * (double)_perf.loops; + _progTrace("Perf: %.1f frames/s shown, %" PRIu64 " loops; per loop %.1f ms (worst %.1f): disc %.1f, script %.1f, scene %.1f, gui %.1f, overlay %.1f, compose %.1f, present %.1f, other %.1f%s", (double)_perf.presents * (double)SDL_NS_PER_SECOND / (double)(now - _perf.windowStartNS), _perf.loops, (double)_perf.totalNS / perLoop, (double)_perf.worstNS / (double)SDL_NS_PER_MS, (double)_perf.discNS / perLoop, (double)_perf.scriptNS / perLoop, (double)_perf.sceneNS / perLoop, (double)_perf.guiNS / perLoop, (double)_perf.overlayNS / perLoop, (double)_perf.composeNS / perLoop, (double)_perf.presentNS / perLoop, (double)(_perf.totalNS - _perf.discNS - _perf.scriptNS - _perf.sceneNS - _perf.guiNS - _perf.overlayNS - _perf.composeNS - _perf.presentNS) / perLoop, _global.conf->perfSync ? " (GPU time included)" : ""); + if (_perfCalls.count > 0) { + PerfCallT *sorted = malloc(sizeof(PerfCallT) * (size_t)_perfCalls.count); + char line[PERF_LINE_BYTES]; + size_t used = 0; + int32_t x = 0; + + if (sorted != NULL) { + memcpy(sorted, _perfCalls.call, sizeof(PerfCallT) * (size_t)_perfCalls.count); + qsort(sorted, (size_t)_perfCalls.count, sizeof(PerfCallT), _perfCompare); + used = (size_t)snprintf(line, sizeof(line), "Perf script: per loop %.1f ms of Lua and %.1f in engine calls it made; %.1f in engine calls altogether:", (double)(_perf.scriptNS - SDL_min(_perfCalls.scriptNS, _perf.scriptNS)) / perLoop, (double)_perfCalls.scriptNS / perLoop, (double)_perfCalls.ns / perLoop); + for (x = 0; (x < PERF_TOP_CALLS) && (x < _perfCalls.count) && (sorted[x].calls > 0) && (used < sizeof(line)); x++) { + used += (size_t)snprintf(line + used, sizeof(line) - used, "%s %s %.2f (%.1f calls)", (x == 0) ? "" : ",", sorted[x].name, (double)sorted[x].ns / perLoop, (double)sorted[x].calls / (double)_perf.loops); + } + _progTrace("%s", line); + free(sorted); + } + for (x = 0; x < _perfCalls.count; x++) { + _perfCalls.call[x].ns = 0; + _perfCalls.call[x].calls = 0; + } + _perfCalls.ns = 0; + _perfCalls.scriptNS = 0; + } + _perf.windowStartNS = now; + _perf.loops = 0; + _perf.presents = 0; + _perf.discNS = 0; + _perf.scriptNS = 0; + _perf.sceneNS = 0; + _perf.guiNS = 0; + _perf.overlayNS = 0; + _perf.composeNS = 0; + _perf.presentNS = 0; + _perf.totalNS = 0; + _perf.worstNS = 0; +} + + +// Wraps every engine call _registerApi just added -- the globals that are C functions without +// upvalues and are not in the snapshot of the globals from before, which sits on the top of the +// stack -- in _perfCall. Lua's own libraries were there before, so pcall and its kind are left +// alone: timing them would bill all the Lua they run to the engine. +static void _perfWrapApi(lua_State *L) { + int32_t snapshot = lua_gettop(L); + PerfCallT *grown = NULL; + int32_t x = 0; + bool bare = false; + + for (x = 0; x < _perfCalls.count; x++) { + free(_perfCalls.call[x].name); + } + free(_perfCalls.call); + memset(&_perfCalls, 0, sizeof(_perfCalls)); + lua_pushglobaltable(L); + lua_pushnil(L); + while (lua_next(L, -2)) { + // lua_getupvalue pushes the upvalue when there is one, so a closure's is popped straight away. + bare = false; + if (lua_iscfunction(L, -1)) { + if (lua_getupvalue(L, -1, 1) == NULL) { + bare = true; + } else { + lua_pop(L, 1); + } + } + if (bare && (lua_type(L, -2) == LUA_TSTRING)) { + lua_pushvalue(L, -2); + lua_rawget(L, snapshot); + if (lua_isnil(L, -1)) { + grown = realloc(_perfCalls.call, sizeof(PerfCallT) * (size_t)(_perfCalls.count + 1)); + if (grown != NULL) { + _perfCalls.call = grown; + memset(&_perfCalls.call[_perfCalls.count], 0, sizeof(PerfCallT)); + _perfCalls.call[_perfCalls.count].name = strdup(lua_tostring(L, -3)); + // Replacing a field that exists is allowed while lua_next walks the table. + lua_pushvalue(L, -3); + lua_pushvalue(L, -3); + lua_pushinteger(L, _perfCalls.count); + lua_pushcclosure(L, _perfCall, 2); + lua_rawset(L, -6); + _perfCalls.count++; + } + } + lua_pop(L, 1); + } + lua_pop(L, 1); + } + lua_pop(L, 1); +} + + +// Whether a key or button is physically down right now. +static bool _physicallyHeld(int32_t scancode) { + int32_t i = 0; + + for (i = 0; i < _global.physicalKeyCount; i++) { + if (_global.physicalKeys[i].scancode == scancode) { + return true; + } + } + + return false; +} + + // Hands the step's contacts and trigger overlaps to the script: onCollision(nodeA, nodeB, x, y, z, // speed) and onTrigger(trigger, other, entered), when the script defines them. Physics keeps what // one batch cannot hold, so the queue is drained until empty. @@ -5468,10 +6112,10 @@ static void _presentSize(int32_t *width, int32_t *height) { } -// Routes a key, button, or axis direction code: engine switches first, then the script. +// Routes a key, button, or axis direction code: recorded as held, perhaps held back a moment for the +// quit chord, then acted on by _actOnKey -- engine switches first, then the script. static void _processKey(bool down, int32_t keysym, int32_t scancode) { - InputE engine = INPUT_COUNT; - bool keyboard = (scancode < CODE_GAMEPAD_BASE); // Every defined scancode is below the gamepad range + bool keyboard = (scancode < CODE_GAMEPAD_BASE); // Every defined scancode is below the gamepad range // Physical state is tracked even while the game is frozen. if (down) { @@ -5484,49 +6128,13 @@ static void _processKey(bool down, int32_t keysym, int32_t scancode) { } _heldListUpdate(_global.physicalKeys, &_global.physicalKeyCount, down, keysym, scancode); - // Engine owned switches act on the press. Keyboard mappings only count in MODE_NORMAL, so - // full mode keeps every key for the game; gamepad and mouse buttons cannot be typed, so they - // always count. - if ((_global.keyboardMode == KEYBOARD_NORMAL) || !keyboard) { - engine = _engineSwitch(scancode); - } - if (down && _global.conf->reload && (scancode == SDL_SCANCODE_F5)) { - _global.reloadRequested = true; - } - if (down) { - switch (engine) { - case INPUT_PAUSE: - if (_global.pauseEnabled) { - _setPause(!_global.pauseState, true); - } - break; - - case INPUT_GRAB: - _setMouseCaptured(!_global.mouseGrabbed); - break; - - case INPUT_QUIT: - if (_global.quitEnabled) { - _global.running = false; - } - break; - - case INPUT_SCREENSHOT: - // Force a redraw so the shot is taken now, even while paused. - _global.requestScreenShot = true; - _global.refreshDisplay = true; - break; - - default: - break; - } - } - - // The script never sees the pause key while the engine owns it, or anything while frozen. - if (_global.frozen || ((engine == INPUT_PAUSE) && _global.pauseEnabled)) { + if (down && _quitChordHold(scancode)) { return; } - _deliverKey(down, keysym, scancode); + if (!down) { + _quitChordLetGo(scancode); + } + _actOnKey(down, keysym, scancode); } @@ -5854,6 +6462,57 @@ static void _quadScratch(int32_t quads, int32_t frames) { } +// A press of one of the quit chord's buttons, before the rest of the chord is down, waits: the +// button's own meaning -- Start launches a game from the menu -- would otherwise happen on the way +// to quitting, and in the menu instead of it. True if the press is held back. It goes on when +// the chord completes, dropped, or when the button is let go or QUIT_CHORD_WAIT_MS passes, late. +static bool _quitChordHold(int32_t scancode) { + bool member = false; + bool complete = true; + int32_t c = 0; + + if ((_global.quitChord.count == 0) || _global.quitChord.active || (_global.quitChordHeldCount >= CHORD_CODES_MAX)) { + return false; + } + for (c = 0; c < _global.quitChord.count; c++) { + member = member || (_global.quitChord.code[c] == scancode); + complete = complete && _physicallyHeld(_global.quitChord.code[c]); + } + if (!member || complete) { + return false; + } + if (_global.quitChordHeldCount == 0) { + _global.quitChordHeldMS = utilTicks(); + } + _global.quitChordHeld[_global.quitChordHeldCount++] = scancode; + + return true; +} + + +// Lets the held back presses through, ahead of the release of one of them, or of all of them for -1: +// the wait ran out. +static void _quitChordLetGo(int32_t scancode) { + int32_t held[CHORD_CODES_MAX]; + int32_t count = _global.quitChordHeldCount; + bool found = (scancode < 0); + int32_t x = 0; + + for (x = 0; x < count; x++) { + found = found || (_global.quitChordHeld[x] == scancode); + } + if (!found) { + return; + } + // Emptied first: the presses go through _actOnKey, which must not see them as held. + memcpy(held, _global.quitChordHeld, sizeof(int32_t) * (size_t)count); + _global.quitChordHeldCount = 0; + for (x = 0; x < count; x++) { + _actOnKey(true, 0, held[x]); + } +} + + // colorXxx(r, g, b[, a]) with components clamped to 0..255. static void _readColor(lua_State *L, const char *method, SDL_Color *color, uint8_t defaultAlpha) { int32_t n = lua_gettop(L); @@ -5872,6 +6531,57 @@ static void _readColor(lua_State *L, const char *method, SDL_Color *color, uint8 } +// Adds a rectangle, or everything for NULL. A full region merges it into the rectangle it grows least. +static void _regionAdd(RegionT *region, const SDL_Rect *area) { + SDL_Rect merged; + int64_t growth = 0; + int64_t least = INT64_MAX; + int32_t closest = 0; + int32_t x = 0; + + if (region->all) { + return; + } + if (area == NULL) { + region->all = true; + region->count = 0; + return; + } + if (region->count < REGION_RECTS_MAX) { + region->rect[region->count++] = *area; + return; + } + for (x = 0; x < REGION_RECTS_MAX; x++) { + SDL_GetRectUnion(®ion->rect[x], area, &merged); + growth = (int64_t)merged.w * merged.h - (int64_t)region->rect[x].w * region->rect[x].h; + if (growth < least) { + least = growth; + closest = x; + } + } + SDL_GetRectUnion(®ion->rect[closest], area, ®ion->rect[closest]); +} + + +static void _regionAddRegion(RegionT *region, const RegionT *other) { + int32_t x = 0; + + if (other->all) { + _regionAdd(region, NULL); + return; + } + for (x = 0; x < other->count; x++) { + _regionAdd(region, &other->rect[x]); + } +} + + +static void _regionClear(RegionT *region) { + region->count = 0; + region->all = false; +} + + // Every Singe call a script may make. Comments give the version each call appeared in. static void _registerApi(lua_State *L) { lua_register(L, "allowSocketCall", apiAllowSocketCall); // Hypseus @@ -6005,6 +6715,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "guiLoad", apiGuiLoad); // 3.00 lua_register(L, "guiLoadFont", apiGuiLoadFont); // 3.00 lua_register(L, "guiNew", apiGuiNew); // 3.00 + lua_register(L, "guiRefresh", apiGuiRefresh); // 3.00 lua_register(L, "guiSetHandler", apiGuiSetHandler); // 3.00 lua_register(L, "guiSetInput", apiGuiSetInput); // 3.00 lua_register(L, "guiSetValue", apiGuiSetValue); // 3.00 @@ -6469,6 +7180,62 @@ static SDL_Surface *_renderText(lua_State *L, const char *method, const char *me } +// _renderText through the cache, for fontPrint. The surface stays the cache's: blit it, never free it. +static SDL_Surface *_renderTextCached(lua_State *L, const char *method, const char *message) { + TextCacheT *entry = NULL; + TextCacheT *entryTemp = NULL; + SDL_Surface *surface = NULL; + char *grown = NULL; + size_t bytes = strlen(message) + TEXT_CACHE_KEY_HEADER; + uint64_t now = utilTicks(); + SDL_Color foreground = _global.colorForeground; + SDL_Color background = { 0, 0, 0, 0 }; + + if (_global.fontCurrent == NULL) { + return _renderText(L, method, message); + } + if (now - _global.textCacheSweepMS >= TEXT_CACHE_KEEP_MS) { + HASH_ITER(hh, _global.textCache, entry, entryTemp) { + if (entry->usedMS < _global.textCacheSweepMS) { + _textCacheRemove(entry); + } + } + _global.textCacheSweepMS = now; + } + if (bytes > _global.textCacheKeyBytes) { + grown = realloc(_global.textCacheKey, bytes); + if (grown == NULL) { + utilDie("Out of memory in %s.", method); + } + _global.textCacheKey = grown; + _global.textCacheKeyBytes = bytes; + } + // Only shaded text has a background. + if (_global.fontQuality == FONT_QUALITY_SHADED) { + background = _global.colorBackground; + } + snprintf(_global.textCacheKey, _global.textCacheKeyBytes, "%d %d %02x%02x%02x%02x %02x%02x%02x%02x %s", _global.fontCurrent->id, (int32_t)_global.fontQuality, foreground.r, foreground.g, foreground.b, foreground.a, background.r, background.g, background.b, background.a, message); + HASH_FIND_STR(_global.textCache, _global.textCacheKey, entry); + if (entry == NULL) { + // Rendered first, so a font that fails raises its Lua error with nothing half added. + surface = _renderText(L, method, message); + entry = calloc(1, sizeof(TextCacheT)); + if (entry == NULL) { + utilDie("Out of memory in %s.", method); + } + entry->key = strdup(_global.textCacheKey); + entry->surface = surface; + entry->font = _global.fontCurrent->id; + // Run length encoded, since it will be blitted with its colour key again and again. + SDL_SetSurfaceRLE(entry->surface, true); + HASH_ADD_KEYPTR(hh, _global.textCache, entry->key, strlen(entry->key), entry); + } + entry->usedMS = now; + + return entry->surface; +} + + // Everything a script sets about itself, back to what a script may expect at its start: colours, // font quality, keyboard mode, listener, pause, the effect channels, their volume and their // completion queue, the mouse (mode, enabled, captured), the disc parked on frame 1, what the @@ -7546,9 +8313,10 @@ static const char *_startAudioSuffix(void) { static void _startControllers(void) { int32_t order[MAX_CONTROLLERS]; - int32_t slot = 0; - int32_t count = 0; - SDL_JoystickID *ids = NULL; + int32_t slot = 0; + int32_t count = 0; + SDL_JoystickID *ids = NULL; + char *mapping = NULL; _stopControllers(); @@ -7575,7 +8343,9 @@ static void _startControllers(void) { } _global.controllers[slot] = SDL_OpenGamepad(ids[order[slot]]); if (_global.controllers[slot]) { - _progTrace("Found %d - %s", slot, SDL_GetGamepadName(_global.controllers[slot])); + mapping = SDL_GetGamepadMapping(_global.controllers[slot]); + _progTrace("Found %d - %s: %s", slot, SDL_GetGamepadName(_global.controllers[slot]), (mapping != NULL) ? mapping : "no mapping"); + SDL_free(mapping); } else { _progTrace("Controller %d not opened", slot); } @@ -7701,6 +8471,7 @@ static void _stopControllers(void) { // The 3D side a script builds on: scene, physics, particles and navigation, in dependency order. static void _subsystemsInit(void) { sceneInit(_global.device, _global.renderer); + sceneSetLowQuality(_global.conf->sceneLow); guiInit(_global.device, _global.renderer, _global.window); guiSetEventCallback(_guiEvent); physicsInit(); @@ -8134,6 +8905,28 @@ static void _takeScreenshot(void) { } +// Forgets a font's rendered strings, or every font's for -1. +static void _textCacheDrop(int32_t font) { + TextCacheT *entry = NULL; + TextCacheT *entryTemp = NULL; + + HASH_ITER(hh, _global.textCache, entry, entryTemp) { + if ((font < 0) || (entry->font == font)) { + _textCacheRemove(entry); + } + } +} + + +// Takes one string out of the cache and frees it. +static void _textCacheRemove(TextCacheT *entry) { + HASH_DEL(_global.textCache, entry); + SDL_DestroySurface(entry->surface); + free(entry->key); + free(entry); +} + + // Alt-Enter. Only the window changes: the renderer presents a logical size in video coordinates // and scales it itself, _computeVideoRect() works in those same coordinates, the rotation target is // the canvas's size and not the window's, and the GUIs are drawn into rectangles measured in the @@ -8173,6 +8966,10 @@ static void _unloadScriptResources(void) { _progTrace("Unloading font handle %d", font->id); _fontDestroy(font); } + // Every font's strings went with it; only the scratch for keys is left. + free(_global.textCacheKey); + _global.textCacheKey = NULL; + _global.textCacheKeyBytes = 0; HASH_ITER(hh, _global.musicList, music, musicTemp) { _progTrace("Unloading music handle %d", music->id); _musicDestroy(music); @@ -9860,12 +10657,11 @@ static int32_t apiFontPrint(lua_State *L) { dest.x = _argInteger(L, "fontPrint", 1); dest.y = _argInteger(L, "fontPrint", 2); message = _argString(L, "fontPrint", 3); - text = _renderText(L, "fontPrint", message); + text = _renderTextCached(L, "fontPrint", message); dest.w = text->w; dest.h = text->h; SDL_BlitSurface(text, NULL, _global.overlay, &dest); - SDL_DestroySurface(text); - _overlayTouched(); + _overlayTouched(&dest); _luaTrace(L, "fontPrint", "%s", message); return 0; @@ -10126,6 +10922,20 @@ static int32_t apiGuiNew(lua_State *L) { } +// guiRefresh(gui) Draws the GUI again next frame even if nothing seems to have changed. A GUI +// that looks the same as last frame is not redrawn; this is the way out should that ever be wrong. +static int32_t apiGuiRefresh(lua_State *L) { + int32_t gui; + + _argCheck(L, "guiRefresh", 1, 1); + gui = _argGui(L, "guiRefresh", 1); + guiInvalidate(gui); + _luaTrace(L, "guiRefresh", "%d", gui); + + return 0; +} + + // guiSetHandler(gui, document, id, event, function): calls function(gui, document, id, event, value) // when the element with that id fires the event ("click", "change", "submit", ...); nil removes it static int32_t apiGuiSetHandler(lua_State *L) { @@ -12294,6 +13104,7 @@ static int32_t apiOverlayBox(lua_State *L) { int32_t x2 = 0; int32_t y2 = 0; uint32_t pixel = 0; + SDL_Rect area; _argCheck(L, "overlayBox", 4, 4); x1 = _argInteger(L, "overlayBox", 1); @@ -12301,13 +13112,14 @@ static int32_t apiOverlayBox(lua_State *L) { x2 = _argInteger(L, "overlayBox", 3); y2 = _argInteger(L, "overlayBox", 4); pixel = _overlayColor(&_global.colorForeground); + area = _overlayArea(x1, y1, x2, y2, 0); SDL_LockSurface(_global.overlay); _drawLine(x1, y1, x2, y1, pixel); _drawLine(x2, y1, x2, y2, pixel); _drawLine(x2, y2, x1, y2, pixel); _drawLine(x1, y2, x1, y1, pixel); SDL_UnlockSurface(_global.overlay); - _overlayTouched(); + _overlayTouched(&area); _luaTrace(L, "overlayBox", "%d %d %d %d", x1, y1, x2, y2); return 0; @@ -12325,6 +13137,7 @@ static int32_t apiOverlayCircle(lua_State *L) { int32_t dy = 1; int32_t err = 0; uint32_t pixel = 0; + SDL_Rect area; _argCheck(L, "overlayCircle", 3, 3); x0 = _argInteger(L, "overlayCircle", 1); @@ -12333,6 +13146,7 @@ static int32_t apiOverlayCircle(lua_State *L) { x = r - 1; err = dx - (r << 1); pixel = _overlayColor(&_global.colorForeground); + area = _overlayArea(x0 - r, y0 - r, x0 + r, y0 + r, 0); SDL_LockSurface(_global.overlay); while (x >= y) { @@ -12356,7 +13170,7 @@ static int32_t apiOverlayCircle(lua_State *L) { } } SDL_UnlockSurface(_global.overlay); - _overlayTouched(); + _overlayTouched(&area); _luaTrace(L, "overlayCircle", "%d %d %d", x0, y0, r); return 0; @@ -12365,8 +13179,50 @@ static int32_t apiOverlayCircle(lua_State *L) { // overlayClear() Fills with the background color. static int32_t apiOverlayClear(lua_State *L) { - SDL_FillSurfaceRect(_global.overlay, NULL, _overlayColor(&_global.colorBackground)); - _overlayTouched(); + uint32_t color = _overlayColor(&_global.colorBackground); + const SDL_Rect *area = NULL; + uint8_t *row = NULL; + int32_t x = 0; + int32_t y = 0; + + if (_global.overlay != _global.overlayBase) { + SDL_FillSurfaceRect(_global.overlay, NULL, color); + _overlayTouched(NULL); + _luaTrace(L, "overlayClear", "Cleared the layer."); + return 0; + } + // Clearing to the colour already there only has to refill what was drawn since: a HUD of a few + // strings over a picture that is otherwise all background. Nothing drawn at all, as in a menu + // whose picture is all GUI and 3D, changes nothing, and the texture is not even uploaded again. + // The videos under it are erased too. + if (_global.overlayVideoCount > 0) { + _global.overlayVideoCount = 0; + _global.overlayDirty = true; + } + if (!_global.overlayPainted.all && (_global.overlayClearColor == color)) { + if (_global.overlayPainted.count == 0) { + _luaTrace(L, "overlayClear", "Already clear."); + return 0; + } + // Row by row with ordinary stores: SDL's x86 fill streams past the cache, which suits a whole + // surface and makes a few small rectangles slower than clearing everything. + for (x = 0; x < _global.overlayPainted.count; x++) { + area = &_global.overlayPainted.rect[x]; + row = (uint8_t *)_global.overlay->pixels + (size_t)area->y * (size_t)_global.overlay->pitch + (size_t)area->x * sizeof(uint32_t); + for (y = 0; y < area->h; y++) { + SDL_memset4(row, color, (size_t)area->w); + row += _global.overlay->pitch; + } + } + _regionAddRegion(&_global.overlayChanged, &_global.overlayPainted); + } else { + SDL_FillSurfaceRect(_global.overlay, NULL, color); + _regionAdd(&_global.overlayChanged, NULL); + } + _regionClear(&_global.overlayPainted); + _global.overlayClearColor = color; + _global.overlayClearAlpha = _global.colorBackground.a; + _global.overlayDirty = true; _luaTrace(L, "overlayClear", "Cleared."); return 0; @@ -12387,6 +13243,7 @@ static int32_t apiOverlayEllipse(lua_State *L) { int32_t err = 0; int32_t e2 = 0; uint32_t pixel = 0; + SDL_Rect area; _argCheck(L, "overlayEllipse", 4, 4); x0 = _argInteger(L, "overlayEllipse", 1); @@ -12394,6 +13251,7 @@ static int32_t apiOverlayEllipse(lua_State *L) { x1 = _argInteger(L, "overlayEllipse", 3); y1 = _argInteger(L, "overlayEllipse", 4); pixel = _overlayColor(&_global.colorForeground); + area = _overlayArea(x0, y0, x1, y1, 1); _luaTrace(L, "overlayEllipse", "%d %d %d %d", x0, y0, x1, y1); a = abs(x1 - x0); @@ -12445,7 +13303,7 @@ static int32_t apiOverlayEllipse(lua_State *L) { y1--; } SDL_UnlockSurface(_global.overlay); - _overlayTouched(); + _overlayTouched(&area); return 0; } @@ -12527,6 +13385,7 @@ static int32_t apiOverlayLine(lua_State *L) { int32_t x2 = 0; int32_t y2 = 0; uint32_t pixel = 0; + SDL_Rect area; _argCheck(L, "overlayLine", 4, 4); x1 = _argInteger(L, "overlayLine", 1); @@ -12534,10 +13393,11 @@ static int32_t apiOverlayLine(lua_State *L) { x2 = _argInteger(L, "overlayLine", 3); y2 = _argInteger(L, "overlayLine", 4); pixel = _overlayColor(&_global.colorForeground); + area = _overlayArea(x1, y1, x2, y2, 0); SDL_LockSurface(_global.overlay); _drawLine(x1, y1, x2, y2, pixel); SDL_UnlockSurface(_global.overlay); - _overlayTouched(); + _overlayTouched(&area); _luaTrace(L, "overlayLine", "%d %d %d %d", x1, y1, x2, y2); return 0; @@ -12549,15 +13409,17 @@ static int32_t apiOverlayPlot(lua_State *L) { int32_t x = 0; int32_t y = 0; uint32_t pixel = 0; + SDL_Rect area; _argCheck(L, "overlayPlot", 2, 2); x = _argInteger(L, "overlayPlot", 1); y = _argInteger(L, "overlayPlot", 2); pixel = _overlayColor(&_global.colorForeground); + area = _overlayArea(x, y, x, y, 0); SDL_LockSurface(_global.overlay); _putPixel(x, y, pixel); SDL_UnlockSurface(_global.overlay); - _overlayTouched(); + _overlayTouched(&area); _luaTrace(L, "overlayPlot", "%d %d", x, y); return 0; @@ -12572,6 +13434,7 @@ static int32_t apiOverlayPrint(lua_State *L) { int32_t fit = 0; SDL_Rect src; SDL_Rect dst; + SDL_Rect area; _argCheck(L, "overlayPrint", 3, 3); dst.x = _argInteger(L, "overlayPrint", 1) * _global.consoleFontWidth; @@ -12593,12 +13456,14 @@ static int32_t apiOverlayPrint(lua_State *L) { if (length > fit) { length = fit; } + area = dst; + area.w = length * _global.consoleFontWidth; for (i = 0; i < length; i++) { src.x = text[i] * _global.consoleFontWidth; SDL_BlitSurface(_global.consoleFontSurface, &src, _global.overlay, &dst); dst.x += _global.consoleFontWidth; } - _overlayTouched(); + _overlayTouched(&area); return 0; } @@ -12608,7 +13473,9 @@ static int32_t apiOverlayPrint(lua_State *L) { static int32_t apiOverlaySetMonochrome(lua_State *L) { _argCheck(L, "overlaySetMonochrome", 1, 1); _global.overlayMonochrome = _argBoolean(L, "overlaySetMonochrome", 1); - _overlayTouched(); + // Every pixel shown changes, though none is drawn. + _regionAdd(&_global.overlayChanged, NULL); + _global.overlayDirty = true; _luaTrace(L, "overlaySetMonochrome", "%d", _global.overlayMonochrome); return 0; @@ -13681,6 +14548,74 @@ static int32_t apiSingeQuit(lua_State *L) { } +// SDL ranks gamepad mappings: its own database lowest, then SDL_AddGamepadMapping, where +// _loadGamepadDatabase puts a gamecontrollerdb.txt, and SDL_GAMECONTROLLERCONFIG highest. A system +// that sets that variable -- ArkOS does, with a mapping for its own pad that leaves Select and Start +// out -- would beat any gamecontrollerdb.txt. So before SDL starts, the files the first script would +// read go on the end of that hint, where a later line for a pad replaces an earlier one. SDL reads +// the hint once, so a later game's own file still only gets _loadGamepadDatabase. +void singeGamepadHint(const ConfigT *conf) { + const char *existing = SDL_GetHint(SDL_HINT_GAMECONTROLLERCONFIG); + char *places[SETTINGS_PLACES]; + char *hint = strdup((existing != NULL) ? existing : ""); + char *joined = NULL; + char *data = NULL; + char *line = NULL; + char *save = NULL; + size_t bytes = 0; + int32_t c = 0; + + _configPlaces(GAMEPAD_DATABASE_FILE, conf->dataDir, conf->scriptFile, places); + for (c = 0; c < SETTINGS_PLACES; c++) { + data = ((places[c] != NULL) && vfsExists(places[c])) ? vfsRead(places[c], &bytes) : NULL; + // SDL skips comments in a file, but not in the hint. + for (line = (data != NULL) ? SDL_strtok_r(data, "\r\n", &save) : NULL; line != NULL; line = SDL_strtok_r(NULL, "\r\n", &save)) { + if (line[0] == '#') { + continue; + } + joined = (hint[0] == 0) ? strdup(line) : utilCreateString("%s\n%s", hint, line); + free(hint); + hint = joined; + } + free(data); + free(places[c]); + } + // The variable it extends would otherwise beat a hint set at normal priority. + SDL_SetHintWithPriority(SDL_HINT_GAMECONTROLLERCONFIG, hint, SDL_HINT_OVERRIDE); + free(hint); +} + + +// A quit chord as --quitchord and settings.cfg give it: SDL's names for the first gamepad's buttons +// joined by "+", such as "back+start". SDL's names rather than controls.cfg's, so nothing here keeps +// a second list of them. False for a name SDL does not know, or for fewer than two buttons or more +// than CHORD_CODES_MAX; codes and count may be NULL to check the text alone. +bool singeParseQuitChord(const char *text, int32_t *codes, int32_t *count) { + char *copy = SDL_strdup(text); + char *save = NULL; + char *name = NULL; + int32_t found = 0; + SDL_GamepadButton button = SDL_GAMEPAD_BUTTON_INVALID; + bool ok = copy != NULL; + + for (name = ok ? SDL_strtok_r(copy, "+", &save) : NULL; ok && (name != NULL); name = SDL_strtok_r(NULL, "+", &save)) { + button = SDL_GetGamepadButtonFromString(name); + ok = (button != SDL_GAMEPAD_BUTTON_INVALID) && (found < CHORD_CODES_MAX); + if (ok && (codes != NULL)) { + codes[found] = CODE_GAMEPAD_BASE + CODE_GAMEPAD_BUTTON_OFFSET + (int32_t)button; + } + found++; + } + SDL_free(copy); + ok = ok && (found >= 2); + if (count != NULL) { + *count = ok ? found : 0; + } + + return ok; +} + + // result = singeRelaunch(path [, extra]) // // Ends the engine as singeQuit does and, once everything has been torn down, starts path in its @@ -14361,7 +15296,7 @@ static int32_t apiSpriteDraw(lua_State *L) { } else { SDL_BlitSurface(sprite->surface, NULL, _global.overlay, &dest); } - _overlayTouched(); + _overlayTouched(&dest); _luaTrace(L, "spriteDraw", "%d %d %d %d %d %d", sprite->id, dest.x, dest.y, dest.w, dest.h, center); return 0; @@ -14406,7 +15341,7 @@ static int32_t apiSpriteDrawFrame(lua_State *L) { SDL_BlitSurfaceScaled(source, NULL, _global.overlay, &dest, SDL_SCALEMODE_NEAREST); } } - _overlayTouched(); + _overlayTouched(&dest); _luaTrace(L, "spriteDrawFrame", "%d %d %d %d %d %d", sprite->id, dest.x, dest.y, frame, dest.w, dest.h); return 0; @@ -14435,7 +15370,7 @@ static int32_t apiSpriteDrawGrid(lua_State *L) { dest.w = source.w; dest.h = source.h; SDL_BlitSurface(sprite->surface, &source, _global.overlay, &dest); - _overlayTouched(); + _overlayTouched(&dest); _luaTrace(L, "spriteDrawGrid", "%d %d %d %d %d %d %d", sprite->id, dest.x, dest.y, source.x, source.y, source.w, source.h); return 0; @@ -14474,7 +15409,7 @@ static int32_t apiSpriteDrawRotatedFrame(lua_State *L) { SDL_BlitSurfaceScaled(sprite->rotatedFrame, NULL, _global.overlay, &dest, SDL_SCALEMODE_NEAREST); } } - _overlayTouched(); + _overlayTouched(&dest); _luaTrace(L, "spriteDrawRotatedFrame", "%d %d %d %d %d", sprite->id, dest.x, dest.y, dest.w, dest.h); return 0; @@ -15372,6 +16307,7 @@ static int32_t apiVideoDraw(lua_State *L) { SDL_Surface *source = NULL; SDL_Surface *rgba = NULL; SDL_Rect dest; + SDL_Rect gpu; _argCheck(L, "videoDraw", 4, 5); video = _argVideo(L, "videoDraw", 1); @@ -15386,14 +16322,35 @@ static int32_t apiVideoDraw(lua_State *L) { center = _argBoolean(L, "videoDraw", 4); } - // Advance the video and wrap its decoded frame without copying it. + // Advance the video. Its picture is on its texture; the CPU only needs the pixels to copy them. frame = videoUpdate(video->handle, &video->texture); - if (!videoGetPixels(video->handle, &pixels, &pitch)) { + if (frame < 0) { _luaTrace(L, "videoDraw", "%d no frame yet", video->id); return 0; } newFrame = (frame != video->lastFrame); video->lastFrame = frame; + // Stretched, or at its own size, the GPU can draw it instead, when that looks the same. Tried + // before asking for the pixels: asking makes a zero-copy video copy its frames from then on. + if ((n == 5) || ((video->angle == 0.0) && (video->scaleX == 1.0) && (video->scaleY == 1.0))) { + gpu = dest; + if (n != 5) { + gpu.w = videoGetWidth(video->handle); + gpu.h = videoGetHeight(video->handle); + if (center) { + gpu.x -= gpu.w / 2; + gpu.y -= gpu.h / 2; + } + } + if ((gpu.w > 0) && (gpu.h > 0) && _overlayVideoUnder(video->texture, &gpu)) { + _luaTrace(L, "videoDraw", "%d %d %d %d %d %" PRId64 " by the GPU", video->id, gpu.x, gpu.y, gpu.w, gpu.h, frame); + return 0; + } + } + if (!videoGetPixels(video->handle, &pixels, &pitch)) { + _luaTrace(L, "videoDraw", "%d no pixels yet", video->id); + return 0; + } source = SDL_CreateSurfaceFrom(videoGetWidth(video->handle), videoGetHeight(video->handle), VIDEO_SURFACE_FORMAT, (void *)pixels, pitch); if (source == NULL) { utilDie("%s", SDL_GetError()); @@ -15438,7 +16395,7 @@ static int32_t apiVideoDraw(lua_State *L) { } } SDL_DestroySurface(source); - _overlayTouched(); + _overlayTouched(&dest); _luaTrace(L, "videoDraw", "%d %d %d %d %d %" PRId64, video->id, dest.x, dest.y, dest.w, dest.h, frame); return 0; @@ -15631,7 +16588,9 @@ static int32_t apiVideoLoad(lua_State *L) { if (!video) { _luaDie(L, "videoLoad", "Unable to allocate new video."); } - video->handle = videoLoad(name, NULL, dataDir, _global.renderer, true); + // Indexed in the background, so a long clip does not stop the frame loop; not on the virtual + // clock, where a run has to repeat exactly and waiting is what makes it do so. + video->handle = videoLoad(name, NULL, dataDir, _global.renderer, !utilIsDeterministic()); video->id = _global.nextVideoId++; video->lastFrame = -1; video->scaleX = 1.0; @@ -16402,8 +17361,11 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co MouseT *mouse = NULL; char *audioFile = NULL; uint64_t frameStartNS = 0; + uint64_t stageStartNS = 0; + uint64_t loopNS = 0; int32_t finished[SOUND_QUEUE_SIZE]; int32_t finishedCount = 0; + bool denied = false; // Set up globals memset(&_global, 0, sizeof(GlobalT)); @@ -16418,10 +17380,15 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co _global.window = window; _global.renderer = renderer; _global.device = device; - _subsystemsInit(); - // Local copy of config + // Local copy of config, ahead of the subsystems, which read it _global.conf = cloneConf(conf); + _subsystemsInit(); + _global.quitChordRelease = -1; + _global.quitChordHeldCount = 0; + if (_global.conf->quitChord != NULL) { + singeParseQuitChord(_global.conf->quitChord, _global.quitChord.code, &_global.quitChord.count); + } // --deterministic: the virtual clock and the fixed random stream, both settled before anything // that reads either. The step doubles as the seed, so one number describes the whole run. utilDeterministic(_global.conf->deterministic, (uint32_t)_global.conf->deterministicStep); @@ -16589,13 +17556,17 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // own pointer needs nothing. Only many-mouse mode reads ManyMouse's events -- the poll in the // event loop discards them in single-mouse mode -- so enumerating nothing must not cost a // machine its pointer, or Singe has no mouse at all on a stock Debian or Ubuntu install, in a - // container, or under any account that was never added to that group. + // container, or under any account that was never added to that group. The group is only + // mentioned when it is the reason: a machine with no mouse, a handheld say, is not warned. if ((_global.mouseCount < 1) && _global.mouseEnabled) { + denied = _inputDevicesDenied(); if (_global.conf->manyMouse) { - utilSay("Warning: No mice detected. Mouse input disabled."); + utilSay("Warning: No mice detected. Mouse input disabled.%s", denied ? " This user cannot read the input devices; add it to the \"input\" group." : ""); _global.mouseEnabled = false; + } else if (denied) { + utilSay("Warning: This user cannot read the input devices, so light guns and --manymouse will not work; add it to the \"input\" group. The system pointer still works."); } else { - utilSay("Warning: No mice could be enumerated; using the system pointer. Light guns and --manymouse need this user in the \"input\" group."); + _progTrace("No mice found; using the system pointer"); } } for (x = 0; x < _global.mouseCount; x++) { @@ -16651,7 +17622,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co SDL_SetSurfaceColorKey(_global.consoleFontSurface, true, _surfaceCornerKey(_global.consoleFontSurface)); // Start script + memset(&_perf, 0, sizeof(_perf)); + _perf.scriptStartNS = SDL_GetTicksNS(); _runScript(true); + _perf.windowStartNS = SDL_GetTicksNS(); // Game Loop _progTrace("Script is running"); @@ -16720,9 +17694,16 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co } } code = CODE_GAMEPAD_BASE + slot * CODE_GAMEPAD_STRIDE + CODE_GAMEPAD_BUTTON_OFFSET + event.gbutton.button; + // Which button SDL says it is, by the name controls.cfg and --quitchord use for it. + _progTrace("Pad %d: %s %s", slot, SDL_GetGamepadStringForButton((SDL_GamepadButton)event.gbutton.button), (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) ? "down" : "up"); _processKey(event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN, 0, code); break; + case SDL_EVENT_JOYSTICK_BUTTON_DOWN: + case SDL_EVENT_JOYSTICK_BUTTON_UP: + _joystickButtonTrace(&event.jbutton); + break; + case SDL_EVENT_GAMEPAD_ADDED: case SDL_EVENT_GAMEPAD_REMOVED: case SDL_EVENT_JOYSTICK_ADDED: @@ -16825,6 +17806,13 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co break; } } + if ((_global.quitChordHeldCount > 0) && (utilTicks() - _global.quitChordHeldMS >= QUIT_CHORD_WAIT_MS)) { + _quitChordLetGo(-1); + } + if (mainTerminated() && _global.running) { + _progTrace("Terminated by a signal"); + _global.running = false; + } // Mouse Event Loop - drained even when unused so the queue never fills. while (ManyMouse_PollEvent(&mouseEvent)) { @@ -16934,10 +17922,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // One write for however many keys the frame changed. _saveWrite(); - // How long that took, for the overlay. Keeping it while the overlay is off means turning - // it on shows the last two seconds rather than an empty graph. - _global.statsFrameMs = (double)(SDL_GetTicksNS() - frameStartNS) / (double)SDL_NS_PER_MS; - statsSample(_global.statsFrameMs); + // The overlay redraws every frame while it is up, so its figures stay live. if (statsIsEnabled()) { _global.refreshDisplay = true; } @@ -16957,6 +17942,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co } // Update the disc video + stageStartNS = SDL_GetTicksNS(); if (_global.videoHandle >= 0) { thisFrame = videoUpdate(_global.videoHandle, &_global.videoTexture); if (_global.conf->isFrameFile) { @@ -16985,6 +17971,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // // The throttle is inclusive: on the virtual clock every loop iteration is exactly one // step, and a strict comparison ran the script every other iteration, a disc frame apart. + _perfSync(); + _perf.discNS += SDL_GetTicksNS() - stageStartNS; + stageStartNS = SDL_GetTicksNS(); + _perfCalls.inScript = true; if (!_global.frozen && (utilTicks() >= frameClock)) { intReturn = OVERLAY_NOT_UPDATED; _global.guiDrawCount = 0; @@ -17000,6 +17990,8 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co _global.keyboardLastDown = SDL_SCANCODE_UNKNOWN; _global.keyboardLastUp = SDL_SCANCODE_UNKNOWN; } + _perf.scriptNS += SDL_GetTicksNS() - stageStartNS; + _perfCalls.inScript = false; // Update display if (_global.refreshDisplay || _global.overlayDirty || sceneIsEnabled()) { @@ -17038,10 +18030,16 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co particlesUpdate(!_global.frozen); // The subtitle or the banner is settled before the GUIs render, so a change lands this frame. _subtitleUpdate(); + stageStartNS = SDL_GetTicksNS(); guiUpdate((double)utilTicks() / MS_PER_SECOND_NUMBER); _guiTextInput(); + _perfSync(); + _perf.guiNS += SDL_GetTicksNS() - stageStartNS; + stageStartNS = SDL_GetTicksNS(); sceneUpdateVideo(_sceneVideoSource); sceneTexture = sceneRender(); + _perfSync(); + _perf.sceneNS += SDL_GetTicksNS() - stageStartNS; _updateSounds(); if (sceneTexture != NULL) { SDL_RenderTexture(_global.renderer, sceneTexture, NULL, &_global.videoRect); @@ -17049,18 +18047,34 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // 2D particles beneath the overlay, then the overlay, then the ones above it _drawParticles2D(PARTICLE_UNDER, &_global.videoRect); if (_global.overlayDirty) { + stageStartNS = SDL_GetTicksNS(); overlayShown = _global.overlayMonochrome ? _overlayGrey() : _global.overlayBase; - SDL_UpdateTexture(_global.overlayTexture, NULL, overlayShown->pixels, overlayShown->pitch); + // Into the next of the overlay's textures: rewriting the one the last frame drew from makes a + // tiling GPU wait for that frame or copy the texture first, and ARM's drivers copy. Each + // texture is sent only what changed since it was last brought up to date, a few frames ago. + for (x = 0; x < OVERLAY_TEXTURES; x++) { + _regionAddRegion(&_global.overlayStale[x], &_global.overlayChanged); + } + _regionClear(&_global.overlayChanged); + _global.overlayTextureIndex = (_global.overlayTextureIndex + 1) % OVERLAY_TEXTURES; + _global.overlayTexture = _global.overlayTextures[_global.overlayTextureIndex]; + _overlayUpload(_global.overlayTexture, overlayShown, &_global.overlayStale[_global.overlayTextureIndex]); + _regionClear(&_global.overlayStale[_global.overlayTextureIndex]); _global.overlayDirty = false; + _perfSync(); + _perf.overlayNS += SDL_GetTicksNS() - stageStartNS; } // Artwork in front of the picture goes over it here when the overlay is to stay on // top of the artwork, and after the overlay and the GUIs when it is not. if ((_global.bezelTexture != NULL) && _global.bezelInFront && _global.overlayOnTop) { _bezelDraw(); } + _overlayVideosDraw(&_global.videoRect); SDL_RenderTexture(_global.renderer, _global.overlayTexture, NULL, &_global.videoRect); _subtitleQueue(); + stageStartNS = SDL_GetTicksNS(); _drawGuis(&_global.videoRect, false); + _perf.guiNS += SDL_GetTicksNS() - stageStartNS; _drawParticles2D(PARTICLE_OVER, &_global.videoRect); if ((_global.bezelTexture != NULL) && _global.bezelInFront && !_global.overlayOnTop) { _bezelDraw(); @@ -17068,7 +18082,9 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Screen space GUIs and the score panel are on the artwork, so they come after it. _logicalRect(&screenRect); _scorePanelQueue(); + stageStartNS = SDL_GetTicksNS(); _drawGuis(&screenRect, true); + _perf.guiNS += SDL_GetTicksNS() - stageStartNS; _guisShown(); if (_global.sindenBorder && _global.sindenAtWindow) { _drawSindenBorder(); @@ -17099,10 +18115,32 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // The developer's overlay goes on after the screenshot, so a reference shot is of the // game rather than of the overlay. _statsDraw(); + // With --perfsync the GPU finishes SDL_Renderer's own drawing -- the picture, the overlay, + // the GUIs -- here, so present is left with only the swap. + stageStartNS = SDL_GetTicksNS(); + _perfSync(); + _perf.composeNS += SDL_GetTicksNS() - stageStartNS; // Show it + stageStartNS = SDL_GetTicksNS(); SDL_RenderPresent(_global.renderer); + _perf.presentNS += SDL_GetTicksNS() - stageStartNS; + _perf.presents++; + if (!_perf.firstPresented) { + // Everything a first frame waits on -- shader links, texture uploads -- lands here, and + // a disc or a video that started with the script has been playing all along. + _perf.firstPresented = true; + _progTrace("First frame presented %" PRIu64 " ms after the script started", (SDL_GetTicksNS() - _perf.scriptStartNS) / SDL_NS_PER_MS); + } _global.refreshDisplay = false; } + // How long the whole loop took, drawing and presenting included, for the overlay and for -p. + // Sampled here at the end: taken after input, as it once was, it left out everything that + // is actually slow. Keeping it while the overlay is off means turning it on shows the last + // two seconds rather than an empty graph. The overlay drawn this frame shows the last one. + loopNS = SDL_GetTicksNS() - frameStartNS; + _global.statsFrameMs = (double)loopNS / (double)SDL_NS_PER_MS; + statsSample(_global.statsFrameMs); + _perfTrace(loopNS); // Real time is meaningless on the virtual clock, so a reproducible run does not wait. if (!utilIsDeterministic()) { @@ -17148,7 +18186,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co free(_global.scoreTwinMarkup); SDL_DestroyTexture(_global.pauseTexture); _subsystemsQuit(); - SDL_DestroyTexture(_global.overlayTexture); + _overlayTexturesDestroy(); _overlayLayersFree(); SDL_DestroySurface(_global.overlayBase); SDL_DestroySurface(_global.overlayGrey); @@ -17179,7 +18217,13 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // Release control mappings for (x = 0; x < INPUT_COUNT; x++) { free(_global.controlMappings[x].input); + free(_global.controlMappings[x].chord); } + for (x = 0; x < _perfCalls.count; x++) { + free(_perfCalls.call[x].name); + } + free(_perfCalls.call); + memset(&_perfCalls, 0, sizeof(_perfCalls)); for (x = 0; x < _global.watchedCount; x++) { free(_global.watched[x].name); } diff --git a/src/singe.h b/src/singe.h index 53c6bdb1e..4b41ae02d 100644 --- a/src/singe.h +++ b/src/singe.h @@ -93,7 +93,9 @@ typedef struct ConfigS { char *audioSuffix; // --altaudio: the disc audio suffix discAudioSuffix takes, applied at startup char *soundfont; // --soundfont: the .sf2 MIDI files are synthesised with, in place of the search DeinterlaceE deinterlace; // --deinterlace: what an interlaced picture gets, automatic by default + bool sceneLow; // --scenequality low: half resolution, no antialiasing and no bloom, whatever the script asks char *gamepadOrder; // --gamepad_reorder: enumeration positions, one per gamepad slot, as Hypseus writes them + char *quitChord; // --quitchord: the first gamepad's buttons that quit whatever a game maps, "back+start" say bool resolutionWasCalculated; bool isFrameFile; bool stretchVideo; @@ -106,12 +108,14 @@ typedef struct ConfigS { bool noConsole; bool noLogos; bool programTracing; + bool perfSync; // --perfsync: -p waits for the GPU after each stage, to see where its time goes bool reload; // --reload: watch the loose script files and rerun the game when one changes; F5 too bool scriptTracing; bool legacySpriteArgs; bool bezelFlip; // --bezelflip: the artwork draws in front of the picture instead of behind it bool disc; // Play a laserdisc video; otherwise the canvas is the world bool softwareVideo; // Skip the platform's hardware decoder + bool zeroCopy; // --zerocopy: hardware frames go to the GPU as dma-bufs where they can, on by default bool noGamepad; // --nogamepad: ignore every gamepad, as --nomouse ignores the mice bool noUpdateCheck; // --no_update_check: the menu never asks the service for a newer Singe bool refreshSupport; // --refresh: put the Singe folder aside as old-Singe-vX.YY and write a fresh one @@ -165,6 +169,8 @@ ConfigT *confFromGamesDat(const ConfigT *conf); void settingsFree(SettingT *settings, int32_t count); SettingT *settingsLoad(const ConfigT *conf, int32_t *count); void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, ConfigT *conf); +void singeGamepadHint(const ConfigT *conf); +bool singeParseQuitChord(const char *text, int32_t *codes, int32_t *count); const char *singeRelaunchExtra(void); const char *singeRelaunchPath(void); diff --git a/src/util.c b/src/util.c index 4411f597e..eaa475d66 100644 --- a/src/util.c +++ b/src/util.c @@ -52,6 +52,10 @@ static bool _consoleEnabled = true; static bool _outputHappened = false; static FILE *_utilTraceFile = NULL; +// Run by utilDie before it says anything, so whatever a caller has held back reaches the trace +// ahead of the reason for dying. +static void (*_dieHook)(void) = NULL; + // The virtual clock deterministic mode runs on. utilTicks and utilTicksNS answer from it, and the // frame loop is the only thing that moves it, exactly once a frame, so a frame number always names // the same elapsed time however long the machine took to draw the frames. @@ -170,6 +174,14 @@ void utilDeterministic(bool enable, uint32_t stepMs) { void utilDie(const char *fmt, ...) { va_list args; + if (_dieHook != NULL) { + _dieHook(); + } + if (_utilTraceFile) { + va_start(args, fmt); + _printLine(_utilTraceFile, fmt, args); + va_end(args); + } if (_consoleEnabled) { va_start(args, fmt); _printLine(stderr, fmt, args); @@ -460,6 +472,11 @@ void utilSay(const char *fmt, ...) { } +void utilSetDieHook(void (*hook)(void)) { + _dieHook = hook; +} + + bool utilStartsWith(const char *string, const char *start) { return strncmp(start, string, strlen(start)) == 0; } @@ -535,6 +552,18 @@ void utilTraceEnd(void) { } +// The trace file's descriptor, with everything written so far flushed to it, or -1 when there is no +// trace: for the crash handler, which writes past the stdio buffer. +int32_t utilTraceFd(void) { + if (!_utilTraceFile) { + return -1; + } + fflush(_utilTraceFile); + + return fileno(_utilTraceFile); +} + + void utilTraceStart(const char *filename) { utilTraceEnd(); _utilTraceFile = fopen(filename, "w"); diff --git a/src/util.h b/src/util.h index 14c5fadae..1dbb5728d 100644 --- a/src/util.h +++ b/src/util.h @@ -59,6 +59,7 @@ char *utilReadFile(const char *filename, size_t *bytes); char *utilReadLine(const char *haystack, size_t length, const char **offset); void utilRedirectConsole(void); void utilSay(const char *fmt, ...) __attribute__((format(printf, 1, 2))); +void utilSetDieHook(void (*hook)(void)); bool utilStartsWith(const char *string, const char *start); int32_t utilStricmp(const char *a, const char *b); char *utilStrndup(const char *s1, size_t n); @@ -67,6 +68,7 @@ uint64_t utilTicks(void); uint64_t utilTicksNS(void); void utilTrace(const char *fmt, ...) __attribute__((format(printf, 1, 2))); void utilTraceEnd(void); +int32_t utilTraceFd(void); void utilTraceStart(const char *filename); void utilTraceVArgs(const char *fmt, va_list args) __attribute__((format(printf, 1, 0))); void utilWaitForKeyOnWindows(void); diff --git a/src/vfs.c b/src/vfs.c index 495302879..2b847663f 100644 --- a/src/vfs.c +++ b/src/vfs.c @@ -70,6 +70,11 @@ typedef struct DatabaseS { sqlite3_stmt *assetStmt; // size, data by path sqlite3_stmt *chunkStmt; // data by path and chunk number sqlite3_stmt *listStmt; // paths under a prefix, for directory listings + // Held around every use of the three statements. A video's demuxer reads on its decoder thread + // while its sound is read on the main thread, and one statement shared between them, reset and + // rebound under each other's feet, handed each thread pieces of the other's chunks -- audio that + // decoded as a stream of garbage. SQLite's own mutex covers each call, not a sequence of them. + SDL_Mutex *lock; struct DatabaseS *next; } DatabaseT; @@ -143,10 +148,14 @@ static char *_dataDirKey = NULL; // _dataDirBase normalised without tra // True when any asset lives below the key, which is what a directory is in a database. static bool _assetDirectory(DatabaseT *db, const char *key) { - char *from = _bindListRange(db, key); - bool found = (sqlite3_step(db->listStmt) == SQLITE_ROW); + char *from = NULL; + bool found = false; + SDL_LockMutex(db->lock); + from = _bindListRange(db, key); + found = (sqlite3_step(db->listStmt) == SQLITE_ROW); sqlite3_reset(db->listStmt); + SDL_UnlockMutex(db->lock); free(from); return found; @@ -163,6 +172,7 @@ static bool _assetExists(DatabaseT *db, const char *key) { static bool _assetSize(DatabaseT *db, const char *key, int64_t *size) { bool found = false; + SDL_LockMutex(db->lock); sqlite3_reset(db->assetStmt); sqlite3_bind_text(db->assetStmt, 1, key, -1, SQLITE_STATIC); if (sqlite3_step(db->assetStmt) == SQLITE_ROW) { @@ -170,6 +180,7 @@ static bool _assetSize(DatabaseT *db, const char *key, int64_t *size) { found = true; } sqlite3_reset(db->assetStmt); + SDL_UnlockMutex(db->lock); return found; } @@ -225,6 +236,7 @@ static void _databaseClose(DatabaseT *db) { sqlite3_finalize(db->chunkStmt); sqlite3_finalize(db->listStmt); sqlite3_close(db->db); + SDL_DestroyMutex(db->lock); free(db->path); free(db->loose); free(db->overlay); @@ -274,6 +286,10 @@ static DatabaseT *_databaseOpen(const char *path) { if (db == NULL) { utilDie("Out of memory opening %s.", path); } + db->lock = SDL_CreateMutex(); + if (db->lock == NULL) { + utilDie("%s", SDL_GetError()); + } db->path = strdup(path); if (sqlite3_open_v2(uri, &db->db, SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, NULL) != SQLITE_OK) { free(uri); @@ -563,30 +579,41 @@ static uint8_t *_readAsset(DatabaseT *db, const char *key, size_t *bytes, bool s int64_t offset = 0; int64_t index = 0; int64_t length = 0; + bool found = false; + // The row under the lock, and only the row: the chunks lock for themselves, and nothing may end + // the program while the lock is held, since dying waits for decoder threads that may want it. *bytes = 0; + SDL_LockMutex(db->lock); sqlite3_reset(db->assetStmt); sqlite3_bind_text(db->assetStmt, 1, key, -1, SQLITE_STATIC); - if (sqlite3_step(db->assetStmt) != SQLITE_ROW) { - sqlite3_reset(db->assetStmt); + found = sqlite3_step(db->assetStmt) == SQLITE_ROW; + if (found) { + size = sqlite3_column_int64(db->assetStmt, 0); + if ((size >= 0) && ((uint64_t)size < (uint64_t)SIZE_MAX)) { + data = sdlMemory ? (uint8_t *)SDL_malloc((size_t)size + 1) : (uint8_t *)malloc((size_t)size + 1); + } + if ((data != NULL) && (sqlite3_column_type(db->assetStmt, 1) != SQLITE_NULL)) { + offset = sqlite3_column_bytes(db->assetStmt, 1); + if (offset <= size) { + memcpy(data, sqlite3_column_blob(db->assetStmt, 1), (size_t)offset); + } + } + } + sqlite3_reset(db->assetStmt); + SDL_UnlockMutex(db->lock); + if (!found) { return NULL; } - size = sqlite3_column_int64(db->assetStmt, 0); if ((size < 0) || ((uint64_t)size >= (uint64_t)SIZE_MAX)) { utilDie("%s in %s has an impossible size (%" PRId64 ").", key, db->path, size); } - data = sdlMemory ? (uint8_t *)SDL_malloc((size_t)size + 1) : (uint8_t *)malloc((size_t)size + 1); if (data == NULL) { utilDie("Out of memory reading %s from %s.", key, db->path); } - if (sqlite3_column_type(db->assetStmt, 1) != SQLITE_NULL) { - offset = sqlite3_column_bytes(db->assetStmt, 1); - if (offset > size) { - utilDie("%s in %s holds more data than its size says.", key, db->path); - } - memcpy(data, sqlite3_column_blob(db->assetStmt, 1), (size_t)offset); + if (offset > size) { + utilDie("%s in %s holds more data than its size says.", key, db->path); } - sqlite3_reset(db->assetStmt); while (offset < size) { if (!_readChunk(db, key, index, data + offset, size - offset, &length) || (length == 0)) { utilDie("%s in %s is missing chunk %" PRId64 ".", key, db->path, index); @@ -601,23 +628,30 @@ static uint8_t *_readAsset(DatabaseT *db, const char *key, size_t *bytes, bool s } -// One chunk into a buffer of the given capacity. False when there is no such chunk. +// One chunk into a buffer of the given capacity. False when there is no such chunk. Called from the +// decoder threads as well as this one, so the statement is only ever used under the lock. static bool _readChunk(DatabaseT *db, const char *key, int64_t index, uint8_t *buffer, int64_t capacity, int64_t *length) { - bool found = false; + bool found = false; + bool oversized = false; *length = 0; + SDL_LockMutex(db->lock); sqlite3_reset(db->chunkStmt); sqlite3_bind_text(db->chunkStmt, 1, key, -1, SQLITE_STATIC); sqlite3_bind_int64(db->chunkStmt, 2, index); if (sqlite3_step(db->chunkStmt) == SQLITE_ROW) { - *length = sqlite3_column_bytes(db->chunkStmt, 0); - if ((*length > db->chunkBytes) || (*length > capacity)) { - utilDie("%s in %s has an oversized chunk %" PRId64 ".", key, db->path, index); + *length = sqlite3_column_bytes(db->chunkStmt, 0); + oversized = (*length > db->chunkBytes) || (*length > capacity); + if (!oversized) { + memcpy(buffer, sqlite3_column_blob(db->chunkStmt, 0), (size_t)*length); + found = true; } - memcpy(buffer, sqlite3_column_blob(db->chunkStmt, 0), (size_t)*length); - found = true; } sqlite3_reset(db->chunkStmt); + SDL_UnlockMutex(db->lock); + if (oversized) { + utilDie("%s in %s has an oversized chunk %" PRId64 ".", key, db->path, index); + } return found; } @@ -644,9 +678,9 @@ static bool _resolve(const char *name, TargetT *target) { return true; } - // A path that passes through a database file addresses its contents: "Games/DLe.game/Overlay/x.png". + // A path that passes through a database file addresses its contents: "Games/MyGame.game/Overlay/x.png". // Absolute paths included, so a tool can reach the packed games beside it from inside its own. - // The database file itself ("Games/DLe.game", or with a trailing separator or ".") is its root. + // The database file itself ("Games/MyGame.game", or with a trailing separator or ".") is its root. for (i = extLen; i <= length; i++) { if (((norm[i] == '/') || (norm[i] == 0)) && (strncasecmp(norm + i - extLen, VFS_DATABASE_EXTENSION, extLen) == 0)) { prefix = utilStrndup(norm, i); @@ -910,6 +944,7 @@ char **vfsList(const char *name, int32_t *count) { _listDirectory(target.path, &list); if (target.db != NULL) { _listDirectory(target.overlay, &list); + SDL_LockMutex(target.db->lock); from = _bindListRange(target.db, target.key); prefix = strlen(from); while (sqlite3_step(target.db->listStmt) == SQLITE_ROW) { @@ -920,6 +955,7 @@ char **vfsList(const char *name, int32_t *count) { free(entry); } sqlite3_reset(target.db->listStmt); + SDL_UnlockMutex(target.db->lock); free(from); } _targetFree(&target); diff --git a/src/videoPlayer.c b/src/videoPlayer.c index fb54ed6c2..487650692 100644 --- a/src/videoPlayer.c +++ b/src/videoPlayer.c @@ -46,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,7 @@ #include "../thirdparty/uthash/src/uthash.h" +#include "render.h" #include "util.h" #include "vfs.h" #include "videoPlayer.h" @@ -72,7 +74,8 @@ typedef struct iso639_lang_t iso639_lang_t; #define AUDIO_DRAIN_MS 400 // Long enough for any sane device queue to empty #define AUDIO_MEASURE_MAX_BUFFERS 64 #define AUDIO_MEASURE_TIMEOUT_MS 2000 -#define AUDIO_STREAM_LOW_WATERMARK (24 * 1024) // Bytes queued for the mixer before we stop decoding ahead +#define AUDIO_QUEUE_MS 300 // Audio decoded ahead of the mixer, which rides out a slow frame loop +#define DECODE_ERRORS_MAX 50 // Damaged packets in a row before a stream is taken to be broken rather than damaged #define FILTER_ARGUMENTS_MAX 256 // Longest "video_size=..." the buffer filter is given #define LANGUAGE_CODE_BYTES 8 // An ISO 639 code and room for the odd long one #define SRT_TIME_MAX 16 // "01:23:45,678" and its terminator @@ -106,6 +109,16 @@ typedef struct iso639_lang_t iso639_lang_t; #define LUMA_SAMPLE_MAX 255 // A luma sample is one byte #define SCALER_PLANES 4 // libswscale reads four plane pointers and strides whatever the format #define SEEK_RETRY_MAX 3 // Keyframes to back up when a seek lands past its target +#define PERF_TRACE_NS (5 * SDL_NS_PER_SECOND) // How often the decoder thread traces where its time went +#define VIDEO_TEXTURES 3 // Textures a player takes turns uploading into +#define DRM_FOURCC(a, b, c, d) ((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24)) +#define DRM_NV12 DRM_FOURCC('N', 'V', '1', '2') // Luma, then chroma interleaved: what the decoders give +#define DRM_R8 DRM_FOURCC('R', '8', ' ', ' ') // VAAPI exports NV12 as these two layers +#define DRM_GR88 DRM_FOURCC('G', 'R', '8', '8') +#define HD_HEIGHT 720 // An untagged picture this tall or taller is taken to be BT.709 +#define INDEX_PENDING 0 // indexState: the decoder thread is still building the frame table +#define INDEX_READY 1 // ... it is built and belongs to the main thread as much as the decoder's +#define INDEX_FAILED 2 // ... building it failed; threadErrMsg says why #define STRIDE_ALIGNMENT 64 // libswscale stores with aligned vector instructions; rows must start aligned @@ -163,6 +176,8 @@ typedef struct FrameBufferS { uint8_t *data[SCALER_PLANES]; // Only PLANE_COUNT are ever allocated; the rest stay NULL for libswscale int32_t linesize[SCALER_PLANES]; int64_t frame; // -1 until something has been decoded into it + AVFrame *prime; // The frame as a dma-buf, for the GPU to convert, when it was not copied + bool planes; // data holds this frame: false when it came as a dma-buf and was never copied } FrameBufferT; typedef struct AudioTrackS { @@ -177,9 +192,8 @@ typedef struct VideoPlayerS { char *filename; // The video file, for reopening its own audio bool playing; bool resetTime; - bool rgb; // BGRA frames for scripts to read; otherwise YUV for the GPU - bool monochrome; // Upload neutral chroma planes (YUV players only) - bool blend; // Smooth the luma plane down the picture (YUV players only) + bool monochrome; // Upload neutral chroma planes + bool blend; // Smooth the luma plane down the picture bool flashed; // videoFlash put a white picture on the texture; the next update takes it off int32_t luma; // Eighths added to every luma sample, -4 to 4; 0 leaves the picture alone uint8_t *neutralChroma; // One chroma plane of CHROMA_NEUTRAL, allocated on first use @@ -222,8 +236,16 @@ typedef struct VideoPlayerS { bool packetPending; // videoPacket holds data the decoder refused (EAGAIN) bool hwReported; // First hardware frame has been traced bool v4l2; // Decoding through a V4L2 memory-to-memory device + bool mpp; // ... or Rockchip's MPP, whose frames read back as NV12 bool nv12; // Frames stay NV12 to the texture: no conversion between the decoder and the GPU bool videoHasAudio; // The video file carries an audio stream of its own + int32_t videoErrors; // Damaged packets in a row, skipped until DECODE_ERRORS_MAX + int32_t audioErrors; + uint64_t perfStartNS; // The decoder thread's account of its time, traced every PERF_TRACE_NS + uint64_t perfFrames; + uint64_t perfDecodeNS; // Whole frames, seeking and decoding included + uint64_t perfReadbackNS; // Of that, copying hardware frames into memory + uint64_t perfConvertNS; // And converting them to the player's format // Audio demuxer, decoder, and resampler. Owned by the main thread. AVFormatContext *audioFormat; @@ -254,14 +276,32 @@ typedef struct VideoPlayerS { // Frame buffers and the decoder thread. FrameBufferT front; // What the texture and the script see + uint8_t *pixels; // front in BGRA, for a CPU draw that asks; made on demand + int32_t pixelsPitch; + int64_t pixelsFrame; // Which frame pixels holds, or -1 + struct SwsContext *pixelsSws; // The main thread's own, apart from the decoder thread's sws FrameBufferT back; // What the decoder thread fills bool backReady; bool threadError; bool quitThread; + SDL_AtomicInt indexState; // INDEX_*: a script's video builds its frame table on the decoder thread + SDL_AtomicInt cancelIndex; // Set by videoUnload so an index nobody wants stops early + SDL_Condition *indexed; // Signalled, under lock, when indexState leaves INDEX_PENDING + char *indexPath; // Where the decoder thread keeps the index it builds + bool indexApplied; // The main thread has put a seek made while indexing into range int64_t requestedFrame; // Waiting for the decoder, -1 when none int64_t pendingFrame; // Last frame asked for, so it is not asked for twice int64_t uploadedFrame; // Frame currently on the texture - SDL_Texture *videoTexture; + SDL_Texture *videoTexture; // The one holding the current picture, one of videoTextures or primeTextures + SDL_Renderer *renderer; + SDL_AtomicInt zeroCopy; // 1 while decoded frames reach the GPU as dma-bufs rather than copies + SDL_AtomicInt cpuWanted; // Something wants the pixels themselves, so frames are copied from now on + SDL_Texture *primeTextures[VIDEO_TEXTURES]; // What dma-buf frames are converted into + AVFrame *primeHeld[VIDEO_TEXTURES]; // The frame behind each, kept until its slot comes round again + SDL_Texture *primeShown; // The last picture converted, put back after a flash + bool primeReported; + SDL_Texture *videoTextures[VIDEO_TEXTURES]; + int32_t textureIndex; SDL_Thread *thread; SDL_Mutex *lock; SDL_Condition *wake; @@ -284,9 +324,11 @@ static void _audioSetupResampler(VideoPlayerT *v); static void _audioStartTrack(VideoPlayerT *v, int32_t track); static int _avioRead(void *opaque, uint8_t *buffer, int size); static int64_t _avioSeek(void *opaque, int64_t offset, int whence); -static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char *indexPath); +static bool _buildFrameTable(VideoPlayerT *v, const char *filename, const char *indexPath); static int _compareFrames(const void *a, const void *b); // qsort callback. Not changing int. -static void _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *frame); +static bool _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *frame); +static bool _damaged(VideoPlayerT *v, int32_t *errors, int32_t result, const char *what); +static bool _describePrime(const AVFrame *prime, RenderDmabufT *frame); static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want); static void _decodeHere(VideoPlayerT *v); static const AVFrame *_deinterlace(VideoPlayerT *v, AVFrame *frame); @@ -300,9 +342,11 @@ static AVFormatContext *_formatOpen(const char *filename); static int64_t _frameTime(VideoPlayerT *v, int64_t frame); static VideoPlayerT *_getPlayer(int32_t playerHandle, const char *caller); static char *_indexFileName(const char *filename, const char *indexPath); +static bool _keepPrime(VideoPlayerT *v); static uint8_t *_lumaPlane(VideoPlayerT *v); static void _measureDeviceQueue(void *udata, MIX_Mixer *mixer, const SDL_AudioSpec *spec, float *pcm, int32_t samples); static uint8_t *_neutralChroma(VideoPlayerT *v); +static SDL_Texture *_nextTexture(VideoPlayerT *v); static bool _openDecoder(VideoPlayerT *v, const AVCodec *decoder, const AVStream *stream); static void _openHardware(VideoPlayerT *v, const AVCodec *decoder); static void _openVideo(VideoPlayerT *v, const char *filename); @@ -317,11 +361,15 @@ static enum AVPixelFormat _selectPixelFormat(AVCodecContext *codec, const enum A static bool _srtAppend(char **text, size_t *used, size_t *room, const char *addition); static char *_srtFromRect(const AVSubtitleRect *rect); static void _srtTime(int64_t milliseconds, char *out, size_t size); +static void _stopDecoder(VideoPlayerT *v); static int64_t _streamTimeToMs(int64_t ts, AVRational timeBase); static bool _takeDecodedFrame(VideoPlayerT *v); static void _trackMixed(void *udata, MIX_Track *track, const SDL_AudioSpec *spec, float *pcm, int32_t samples); static void _uploadFrame(VideoPlayerT *v); +static bool _uploadPrime(VideoPlayerT *v); static const AVCodec *_v4l2Decoder(const AVCodec *decoder); +static void _waitIndexed(VideoPlayerT *v); +static void _wantPlanes(VideoPlayerT *v); static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header); @@ -344,6 +392,7 @@ static int64_t _mixLatencyMs = 0; // Time between handi static int32_t _audioDelayMs = 0; // Per-game correction on top of the measured latency static int32_t _audioCalibrationMs = 0; // Per-machine correction, from the calibration screen static bool _hardwareDecoding = true; // Try the platform decoder before falling back to software +static bool _zeroCopyWanted = true; // --zerocopy: hardware frames go to the GPU as dma-bufs where they can // Startup measurement of the audio device queue (see videoInit). static bool _measuring = false; @@ -359,14 +408,9 @@ static void _allocateFrameBuffer(VideoPlayerT *v, FrameBufferT *buffer) { int32_t chromaWidth = (v->width + 1) / 2; int32_t chromaHeight = (v->height + 1) / 2; - buffer->frame = -1; - if (v->rgb) { - buffer->linesize[0] = FFALIGN(v->width * BYTES_PER_PIXEL, STRIDE_ALIGNMENT); - buffer->data[0] = av_malloc((size_t)buffer->linesize[0] * (size_t)v->height); - if (!buffer->data[0]) { - utilDie("Unable to allocate frame buffer."); - } - } else if (v->nv12) { + buffer->frame = -1; + buffer->planes = true; + if (v->nv12) { // One luma plane and one chroma plane carrying U and V in pairs, so it is as wide as luma. buffer->linesize[0] = FFALIGN(v->width, STRIDE_ALIGNMENT); buffer->linesize[1] = buffer->linesize[0]; @@ -700,7 +744,11 @@ static int64_t _avioSeek(void *opaque, int64_t offset, int whence) { // Loads the frame table from the cache, or demuxes the file once to build it and caches the result. -static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char *indexPath) { +// Fills the frame table from the index cache, or by demuxing the whole file and caching the result. +// Runs on the main thread for the disc and on the decoder thread for a script's video, so a failure is +// left in threadErrMsg rather than ending the process from here; false for that, and for a build +// videoUnload cancelled, which leaves no message and no cache file. +static bool _buildFrameTable(VideoPlayerT *v, const char *filename, const char *indexPath) { AVFormatContext *format = NULL; AVPacket *packet = NULL; AVStream *stream = NULL; @@ -716,9 +764,12 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char * int64_t duration = 1; int64_t x = 0; int64_t kept = 0; + bool failed = false; if (!vfsStat(filename, &fileSize, &fileTime)) { - utilDie("Unable to stat %s", filename); + snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "Unable to stat %s", filename); + free(indexName); + return false; } memset(&header, 0, sizeof(header)); memcpy(header.magic, INDEX_MAGIC, sizeof(header.magic)); @@ -729,16 +780,16 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char * if (_readIndexCache(v, indexName, &header)) { free(indexName); - return; + return true; } // Demux the whole file once. Every packet of the video stream is one frame. format = _formatOpen(filename); - if (format == NULL) { - utilDie("Unable to open %s for indexing.", filename); - } - if (avformat_find_stream_info(format, NULL) < 0) { - utilDie("Unable to read stream information from %s.", filename); + if ((format == NULL) || (avformat_find_stream_info(format, NULL) < 0)) { + snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "Unable to open %s for indexing.", filename); + _formatClose(&format); + free(indexName); + return false; } stream = format->streams[v->videoStream]; for (x = 0; x < (int64_t)format->nb_streams; x++) { @@ -746,7 +797,10 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char * } packet = av_packet_alloc(); if (!packet) { - utilDie("Unable to allocate a packet."); + snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "Unable to allocate a packet."); + _formatClose(&format); + free(indexName); + return false; } if (stream->avg_frame_rate.num > 0) { duration = av_rescale_q(1, av_inv_q(stream->avg_frame_rate), stream->time_base); @@ -755,12 +809,18 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char * } } while (av_read_frame(format, packet) >= 0) { + if (SDL_GetAtomicInt(&v->cancelIndex) != 0) { + failed = true; + break; + } if (packet->stream_index == v->videoStream) { if (v->frameCount == capacity) { capacity = (capacity == 0) ? FRAME_TABLE_INITIAL_CAPACITY : capacity * 2; grown = realloc(v->frames, sizeof(FrameInfoT) * (size_t)capacity); if (!grown) { - utilDie("Unable to allocate the frame table."); + snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "Unable to allocate the frame table."); + failed = true; + break; } v->frames = grown; } @@ -778,10 +838,16 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char * } av_packet_unref(packet); } + av_packet_unref(packet); av_packet_free(&packet); _formatClose(&format); - if (v->frameCount == 0) { - utilDie("%s has no video frames.", filename); + if (!failed && (v->frameCount == 0)) { + snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "%s has no video frames.", filename); + failed = true; + } + if (failed) { + free(indexName); + return false; } // Display order, one entry per timestamp. @@ -798,6 +864,8 @@ static void _buildFrameTable(VideoPlayerT *v, const char *filename, const char * _writeIndexCache(v, indexName, &header); utilTrace("Indexed %s: %" PRId64 " frames in %" PRIu64 " ms", filename, kept, SDL_GetTicks() - started); free(indexName); + + return true; } @@ -810,10 +878,12 @@ static int _compareFrames(const void *a, const void *b) { // Puts a decoded frame into our buffer in the player's format, converting only when the decoder's -// output differs (10 bit sources, 4:2:2, full range JPEG sources, hardware formats). -static void _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *frame) { - enum AVPixelFormat wanted = v->rgb ? AV_PIX_FMT_BGRA : (v->nv12 ? AV_PIX_FMT_NV12 : AV_PIX_FMT_YUV420P); - int32_t planes = v->rgb ? 1 : (v->nv12 ? NV12_PLANE_COUNT : PLANE_COUNT); +// output differs (10 bit sources, 4:2:2, full range JPEG sources, hardware formats). Runs on the +// decoder thread, so a failure is left in threadErrMsg for the caller rather than ending the process +// from here: exiting under the main thread's feet crashed in Rockchip's MPP teardown. +static bool _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame *frame) { + enum AVPixelFormat wanted = v->nv12 ? AV_PIX_FMT_NV12 : AV_PIX_FMT_YUV420P; + int32_t planes = v->nv12 ? NV12_PLANE_COUNT : PLANE_COUNT; int32_t plane = 0; int32_t rows = 0; int32_t bytes = 0; @@ -822,27 +892,105 @@ static void _convertFrame(VideoPlayerT *v, FrameBufferT *buffer, const AVFrame * for (plane = 0; plane < planes; plane++) { rows = (plane == 0) ? v->height : (v->height + 1) / 2; // NV12's chroma plane holds a U and a V for every second pixel, so it is luma's width. - bytes = v->rgb ? v->width * BYTES_PER_PIXEL : (((plane == 0) || v->nv12) ? v->width : (v->width + 1) / 2); + bytes = ((plane == 0) || v->nv12) ? v->width : (v->width + 1) / 2; av_image_copy_plane(buffer->data[plane], buffer->linesize[plane], frame->data[plane], frame->linesize[plane], bytes, rows); } } else { v->sws = sws_getCachedContext(v->sws, frame->width, frame->height, (enum AVPixelFormat)frame->format, v->width, v->height, wanted, SWS_BILINEAR, NULL, NULL, NULL); if (!v->sws) { - utilDie("Unable to convert video frames."); + snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "Unable to convert video frames from %s.", av_get_pix_fmt_name((enum AVPixelFormat)frame->format)); + return false; } sws_scale(v->sws, (const uint8_t *const *)frame->data, frame->linesize, 0, frame->height, buffer->data, buffer->linesize); } + + return true; +} + + +// Whether a decoding error is one to skip: a damaged packet, which decoders report in more ways than +// AVERROR_INVALIDDATA (AAC answers some with a bare -1). Running out of memory is not damage, and +// neither is a stream that fails DECODE_ERRORS_MAX times running -- that decoder is broken, and +// skipping forever would only hide it. The first skip in a run is traced so it is not a mystery. +static bool _damaged(VideoPlayerT *v, int32_t *errors, int32_t result, const char *what) { + if (result == AVERROR(ENOMEM)) { + return false; + } + (*errors)++; + if (*errors == 1) { + utilTrace("Video %d: skipped damaged %s (%s)", v->id, what, av_err2str(result)); + } + + return *errors < DECODE_ERRORS_MAX; +} + + +// A DRM PRIME frame as the GPU import takes it: NV12, either as one layer of two planes (MPP, V4L2) +// or as VAAPI's separate R8 and GR88 layers, which are the same two planes. Anything else -- ten bit, +// say -- is not taken, and the player copies its frames instead. +static bool _describePrime(const AVFrame *prime, RenderDmabufT *frame) { + const AVDRMFrameDescriptor *drm = (const AVDRMFrameDescriptor *)prime->data[0]; + const AVDRMPlaneDescriptor *plane = NULL; + int32_t layer = 0; + int32_t p = 0; + + memset(frame, 0, sizeof(RenderDmabufT)); + if (drm == NULL) { + return false; + } + if (!((drm->nb_layers == 1) && (drm->layers[0].format == DRM_NV12)) && !((drm->nb_layers == 2) && (drm->layers[0].format == DRM_R8) && (drm->layers[1].format == DRM_GR88))) { + return false; + } + for (layer = 0; layer < drm->nb_layers; layer++) { + for (p = 0; p < drm->layers[layer].nb_planes; p++) { + if (frame->planes >= RENDER_DMABUF_PLANES) { + return false; + } + plane = &drm->layers[layer].planes[p]; + frame->fd[frame->planes] = drm->objects[plane->object_index].fd; + frame->offset[frame->planes] = (uint32_t)plane->offset; + frame->pitch[frame->planes] = (uint32_t)plane->pitch; + frame->planes++; + } + } + frame->width = prime->width; + frame->height = prime->height; + frame->fourcc = DRM_NV12; + frame->modifier = drm->objects[0].format_modifier; + frame->fullRange = prime->color_range == AVCOL_RANGE_JPEG; + switch (prime->colorspace) { + case AVCOL_SPC_BT709: + frame->colorSpace = RENDER_COLORSPACE_BT709; + break; + case AVCOL_SPC_BT2020_NCL: + case AVCOL_SPC_BT2020_CL: + frame->colorSpace = RENDER_COLORSPACE_BT2020; + break; + case AVCOL_SPC_UNSPECIFIED: + frame->colorSpace = (prime->height >= HD_HEIGHT) ? RENDER_COLORSPACE_BT709 : RENDER_COLORSPACE_BT601; + break; + default: + frame->colorSpace = RENDER_COLORSPACE_BT601; + break; + } + + return frame->planes == 2; } // Decodes frame "want" into the back buffer. Runs on the decoder thread. On DECODE_ERROR the // message is in threadErrMsg; the caller raises threadError under the lock, which publishes both. static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) { - int64_t keyframe = want; - int64_t index = 0; - int64_t ts = 0; - int32_t retries = 0; - int32_t result = 0; + const AVPixFmtDescriptor *desc = NULL; + AVFrame *picture = NULL; + int64_t keyframe = want; + int64_t index = 0; + int64_t ts = 0; + int32_t retries = 0; + int32_t result = 0; + bool ok = false; + uint64_t startNS = SDL_GetTicksNS(); + uint64_t stageNS = 0; // Decide between decoding forward and seeking. Decoding forward from where the decoder sits is // never more work than seeking back to a keyframe at or before it, however far ahead the frame is. @@ -856,34 +1004,64 @@ static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) { for (;;) { result = avcodec_receive_frame(v->videoCodec, v->videoFrame); if (result == 0) { + v->videoErrors = 0; ts = v->videoFrame->best_effort_timestamp; index = (ts == AV_NOPTS_VALUE) ? v->nextDecodeFrame : _findFrameIndex(v, ts); if (index == want) { - if (v->hwDevice && (v->videoFrame->format == v->hwPixelFormat)) { - // Pull the picture out of the GPU; it arrives as NV12 and is converted like any other - // format. hwFrame keeps its buffers between frames so the read back does not allocate. + picture = v->videoFrame; + desc = av_pix_fmt_desc_get((enum AVPixelFormat)v->videoFrame->format); + if ((desc != NULL) && (desc->flags & AV_PIX_FMT_FLAG_HWACCEL) && _keepPrime(v)) { + // Kept as a dma-buf for the GPU to convert: nothing read back, nothing to convert. + picture = NULL; + } else if ((desc != NULL) && (desc->flags & AV_PIX_FMT_FLAG_HWACCEL)) { + // A hardware surface -- VAAPI's or VDPAU's, or the DRM PRIME frames Rockchip's MPP + // decoder hands back -- is read back into memory first. It arrives as NV12 and is + // converted like any other format. hwFrame keeps its buffers between frames so the + // read back does not allocate. if ((v->hwFrame->width != v->videoFrame->width) || (v->hwFrame->height != v->videoFrame->height)) { av_frame_unref(v->hwFrame); } + stageNS = SDL_GetTicksNS(); if (av_hwframe_transfer_data(v->hwFrame, v->videoFrame, 0) < 0) { - snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "Unable to read back a hardware decoded frame."); + snprintf(v->threadErrMsg, sizeof(v->threadErrMsg), "Unable to read back a hardware decoded %s frame.", desc->name); av_frame_unref(v->videoFrame); return DECODE_ERROR; } if (!v->hwReported) { v->hwReported = true; - utilTrace("Video %d: first hardware frame read back as %s %dx%d", v->id, av_get_pix_fmt_name((enum AVPixelFormat)v->hwFrame->format), v->hwFrame->width, v->hwFrame->height); + utilTrace("Video %d: first hardware frame read back from %s as %s %dx%d", v->id, desc->name, av_get_pix_fmt_name((enum AVPixelFormat)v->hwFrame->format), v->hwFrame->width, v->hwFrame->height); } - _convertFrame(v, &v->back, _deinterlace(v, v->hwFrame)); - } else { - if (v->v4l2 && !v->hwReported) { - v->hwReported = true; - utilTrace("Video %d: first V4L2 frame arrived as %s %dx%d", v->id, av_get_pix_fmt_name((enum AVPixelFormat)v->videoFrame->format), v->videoFrame->width, v->videoFrame->height); - } - _convertFrame(v, &v->back, _deinterlace(v, v->videoFrame)); + v->perfReadbackNS += SDL_GetTicksNS() - stageNS; + picture = v->hwFrame; + } else if (v->v4l2 && !v->hwReported) { + v->hwReported = true; + utilTrace("Video %d: first V4L2 frame arrived as %s %dx%d", v->id, av_get_pix_fmt_name((enum AVPixelFormat)v->videoFrame->format), v->videoFrame->width, v->videoFrame->height); + } + stageNS = SDL_GetTicksNS(); + ok = true; + if (picture != NULL) { + av_frame_free(&v->back.prime); + v->back.planes = true; + ok = _convertFrame(v, &v->back, _deinterlace(v, picture)); } av_frame_unref(v->videoFrame); + if (!ok) { + return DECODE_ERROR; + } v->nextDecodeFrame = want + 1; + v->perfConvertNS += SDL_GetTicksNS() - stageNS; + v->perfDecodeNS += SDL_GetTicksNS() - startNS; + v->perfFrames++; + if (v->perfStartNS == 0) { + v->perfStartNS = startNS; + } else if ((SDL_GetTicksNS() - v->perfStartNS) >= PERF_TRACE_NS) { + utilTrace("Video %d perf: %.1f frames/s decoded; per frame %.1f ms, of which read back %.1f, convert %.1f", v->id, (double)v->perfFrames * (double)SDL_NS_PER_SECOND / (double)(SDL_GetTicksNS() - v->perfStartNS), (double)v->perfDecodeNS / (double)SDL_NS_PER_MS / (double)v->perfFrames, (double)v->perfReadbackNS / (double)SDL_NS_PER_MS / (double)v->perfFrames, (double)v->perfConvertNS / (double)SDL_NS_PER_MS / (double)v->perfFrames); + v->perfStartNS = SDL_GetTicksNS(); + v->perfFrames = 0; + v->perfDecodeNS = 0; + v->perfReadbackNS = 0; + v->perfConvertNS = 0; + } return DECODE_OK; } if (index > want) { @@ -912,6 +1090,10 @@ static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) { return DECODE_NONE; } if (result != AVERROR(EAGAIN)) { + // A damaged frame is skipped, as any player skips one; it is not the end of the game. + if (_damaged(v, &v->videoErrors, result, "video")) { + continue; + } av_strerror(result, v->threadErrMsg, sizeof(v->threadErrMsg)); return DECODE_ERROR; } @@ -931,7 +1113,8 @@ static DecodeResultE _decodeFrame(VideoPlayerT *v, int64_t want) { v->packetPending = true; } result = avcodec_send_packet(v->videoCodec, v->videoPacket); - if (result == 0) { + if ((result == 0) || ((result != AVERROR(EAGAIN)) && _damaged(v, &v->videoErrors, result, "video"))) { + // Taken, or damaged and dropped. av_packet_unref(v->videoPacket); v->packetPending = false; } else if (result != AVERROR(EAGAIN)) { @@ -971,6 +1154,24 @@ static int _decoderThread(void *data) { VideoPlayerT *v = (VideoPlayerT *)data; int64_t want = -1; DecodeResultE result = DECODE_NONE; + bool built = false; + + // A script's video builds its frame table here first, so a long file does not stop the frame loop. + // Nothing on the main thread reads the table until indexState says it is ready. + if (SDL_GetAtomicInt(&v->indexState) == INDEX_PENDING) { + built = _buildFrameTable(v, v->filename, v->indexPath); + if (built) { + _reportKeyframes(v, v->filename); + } + SDL_LockMutex(v->lock); + // A cancelled build is not an error: the player is on its way out. + if (!built && (SDL_GetAtomicInt(&v->cancelIndex) == 0)) { + v->threadError = true; + } + SDL_SetAtomicInt(&v->indexState, built ? INDEX_READY : INDEX_FAILED); + SDL_BroadcastCondition(v->indexed); + SDL_UnlockMutex(v->lock); + } SDL_LockMutex(v->lock); while (!v->quitThread) { @@ -1085,12 +1286,18 @@ static bool _deinterlaceOpen(VideoPlayerT *v, const AVFrame *frame) { // Keeps the mixer track's stream topped up from the current audio track. +// Decodes audio ahead until AUDIO_QUEUE_MS of it waits for the mixer. Only the frame loop feeds it, +// so this is how long a loop may take before the sound runs dry and breaks up: 64 ms, which it once +// was, is a few slow frames on a handheld. Nothing is heard any later for it -- the clock counts what +// the mixer has played, and a seek clears what is queued. static void _feedAudio(VideoPlayerT *v) { int32_t result = 0; + int32_t wanted = (int32_t)((int64_t)v->audioSpec.freq * AUDIO_CHANNELS_OUT * BYTES_PER_SAMPLE * AUDIO_QUEUE_MS / (int64_t)MS_PER_SECOND); - while (!v->audioEof && (SDL_GetAudioStreamQueued(v->audioStream) < AUDIO_STREAM_LOW_WATERMARK)) { + while (!v->audioEof && (SDL_GetAudioStreamQueued(v->audioStream) < wanted)) { result = avcodec_receive_frame(v->audioCodec, v->audioFrame); if (result == 0) { + v->audioErrors = 0; _audioQueueFrame(v); av_frame_unref(v->audioFrame); continue; @@ -1099,8 +1306,13 @@ static void _feedAudio(VideoPlayerT *v) { v->audioEof = true; break; } + // A damaged audio packet is a moment of silence, not the end of the game: real releases + // carry them, and dying on one took the whole program down with it. if (result != AVERROR(EAGAIN)) { - utilDie("Audio decoding failed."); + if (_damaged(v, &v->audioErrors, result, "audio")) { + continue; + } + utilDie("Audio decoding failed: %s", av_err2str(result)); } result = av_read_frame(v->audioFormat, v->audioPacket); if (result < 0) { @@ -1108,8 +1320,9 @@ static void _feedAudio(VideoPlayerT *v) { continue; } if (v->audioPacket->stream_index == v->audio[v->currentAudioTrack].streamIndex) { - if (avcodec_send_packet(v->audioCodec, v->audioPacket) < 0) { - utilDie("Audio decoding failed."); + result = avcodec_send_packet(v->audioCodec, v->audioPacket); + if ((result < 0) && !_damaged(v, &v->audioErrors, result, "audio")) { + utilDie("Audio decoding failed: %s", av_err2str(result)); } } av_packet_unref(v->audioPacket); @@ -1229,6 +1442,43 @@ static char *_indexFileName(const char *filename, const char *indexPath) { } +// Keeps the decoded hardware frame as a dma-buf for the GPU to convert, instead of reading it back. +// Not when zero-copy is off, when the pixels are wanted, or when the picture is to be deinterlaced, +// which is a CPU filter; and not for a kind of frame that cannot be had as a dma-buf, which turns +// zero-copy off for good. Runs on the decoder thread. +static bool _keepPrime(VideoPlayerT *v) { + AVFrame *prime = NULL; + bool interlaced = (v->videoFrame->flags & AV_FRAME_FLAG_INTERLACED) != 0; + bool ok = false; + + if ((SDL_GetAtomicInt(&v->zeroCopy) == 0) || (SDL_GetAtomicInt(&v->cpuWanted) != 0) || (v->deinterlace == DEINTERLACE_ON) || (interlaced && (v->deinterlace == DEINTERLACE_AUTO))) { + return false; + } + prime = av_frame_alloc(); + if (prime == NULL) { + return false; + } + if (v->videoFrame->format == AV_PIX_FMT_DRM_PRIME) { + ok = av_frame_ref(prime, v->videoFrame) == 0; + } else { + // VAAPI's surfaces become dma-bufs by mapping, which holds the surface for as long as the mapping lives. + prime->format = AV_PIX_FMT_DRM_PRIME; + ok = av_hwframe_map(prime, v->videoFrame, AV_HWFRAME_MAP_READ) == 0; + } + if (!ok) { + utilTrace("Video %d: %s frames cannot be had as dma-bufs; copying them", v->id, av_get_pix_fmt_name((enum AVPixelFormat)v->videoFrame->format)); + av_frame_free(&prime); + SDL_SetAtomicInt(&v->zeroCopy, 0); + return false; + } + av_frame_free(&v->back.prime); + v->back.prime = prime; + v->back.planes = false; + + return true; +} + + // The scratch luma plane the blend, the luma level and the flash write into, allocated the first // time one of them is asked for. It is the size of a decoded luma plane, stride and all. static uint8_t *_lumaPlane(VideoPlayerT *v) { @@ -1289,6 +1539,17 @@ static uint8_t *_neutralChroma(VideoPlayerT *v) { } +// The texture the next picture goes into, which becomes the current one. Uploading into the texture +// the last frame drew from makes a tiling GPU wait for that frame or copy the texture first -- ARM's +// drivers copy -- so the player takes turns among VIDEO_TEXTURES of them. +static SDL_Texture *_nextTexture(VideoPlayerT *v) { + v->textureIndex = (v->textureIndex + 1) % VIDEO_TEXTURES; + v->videoTexture = v->videoTextures[v->textureIndex]; + + return v->videoTexture; +} + + // Allocates the codec context for the stream, attaches the platform's hardware decoder when there // is one, and opens it. Returns false with the context freed when the decoder will not open. static bool _openDecoder(VideoPlayerT *v, const AVCodec *decoder, const AVStream *stream) { @@ -1329,6 +1590,7 @@ static void _openHardware(VideoPlayerT *v, const AVCodec *decoder) { AV_HWDEVICE_TYPE_NONE }; const AVCodecHWConfig *config = NULL; + const char *node = NULL; int32_t x = 0; int32_t i = 0; @@ -1338,7 +1600,10 @@ static void _openHardware(VideoPlayerT *v, const AVCodec *decoder) { for (x = 0; wanted[x] != AV_HWDEVICE_TYPE_NONE; x++) { for (i = 0; (config = avcodec_get_hw_config(decoder, i)) != NULL; i++) { if ((config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) && (config->device_type == wanted[x])) { - if (av_hwdevice_ctx_create(&v->hwDevice, wanted[x], NULL, NULL, 0) < 0) { + // VAAPI on the GPU the picture is drawn on, where there is a choice, so zero-copy can import + // its frames; the first one otherwise, as before. + node = (_zeroCopyWanted && (wanted[x] == AV_HWDEVICE_TYPE_VAAPI)) ? renderDmabufNode() : NULL; + if (((node == NULL) || (av_hwdevice_ctx_create(&v->hwDevice, wanted[x], node, NULL, 0) < 0)) && (av_hwdevice_ctx_create(&v->hwDevice, wanted[x], NULL, NULL, 0) < 0)) { utilTrace("Hardware decoding via %s is not available; decoding in software.", av_hwdevice_get_type_name(wanted[x])); break; } @@ -1346,12 +1611,11 @@ static void _openHardware(VideoPlayerT *v, const AVCodec *decoder) { v->videoCodec->hw_device_ctx = av_buffer_ref(v->hwDevice); v->videoCodec->get_format = _selectPixelFormat; v->videoCodec->opaque = v; - v->hwFrame = av_frame_alloc(); - if (!v->hwFrame) { - utilDie("Unable to allocate the hardware frame."); - } // The GPU does the work; frame threads would only add latency to every seek. v->videoCodec->thread_count = 1; + // Room for the frames zero-copy holds while the GPU draws them, so the decoder never waits + // on its own pool. A few spare surfaces cost nothing when frames are copied instead. + v->videoCodec->extra_hw_frames = VIDEO_TEXTURES + 1; utilTrace("Hardware decoding via %s.", av_hwdevice_get_type_name(wanted[x])); return; } @@ -1364,6 +1628,7 @@ static void _openHardware(VideoPlayerT *v, const AVCodec *decoder) { static void _openVideo(VideoPlayerT *v, const char *filename) { const AVCodec *decoder = NULL; const AVCodec *v4l2 = NULL; + const AVCodec *mpp = NULL; AVStream *stream = NULL; int32_t x = 0; @@ -1394,12 +1659,11 @@ static void _openVideo(VideoPlayerT *v, const char *filename) { v->fps.den = DEFAULT_FPS_DENOMINATOR; } - v4l2 = _rkmppDecoder(decoder); - if (v4l2 == NULL) { - v4l2 = _v4l2Decoder(decoder); - } + mpp = _rkmppDecoder(decoder); + v4l2 = (mpp != NULL) ? mpp : _v4l2Decoder(decoder); if ((v4l2 != NULL) && _openDecoder(v, v4l2, stream)) { v->v4l2 = true; + v->mpp = (v4l2 == mpp); utilTrace("Hardware decoding via %s.", v4l2->name); } else { if (v4l2 != NULL) { @@ -1409,10 +1673,13 @@ static void _openVideo(VideoPlayerT *v, const char *filename) { utilDie("Unable to open the video decoder for %s.", filename); } } + // hwFrame for every decoder, not only the ones _openHardware attached: MPP's decoders hand back + // hardware frames of their own accord. v->videoPacket = av_packet_alloc(); v->videoFrame = av_frame_alloc(); v->filterFrame = av_frame_alloc(); - if (!v->videoPacket || !v->videoFrame || !v->filterFrame) { + v->hwFrame = av_frame_alloc(); + if (!v->videoPacket || !v->videoFrame || !v->filterFrame || !v->hwFrame) { utilDie("Unable to allocate video decoding buffers."); } v->deinterlace = _deinterlaceMode; @@ -1469,9 +1736,12 @@ static bool _readIndexCache(VideoPlayerT *v, const char *indexName, const IndexH v->frameCount = header.count; v->frames = malloc(sizeof(FrameInfoT) * (size_t)v->frameCount); if (!v->frames) { - utilDie("Unable to allocate the frame table."); + // Built the long way instead, which says so if memory really has run out. + v->frameCount = 0; + valid = false; + } else { + memcpy(v->frames, data + sizeof(IndexHeaderT), sizeof(FrameInfoT) * (size_t)v->frameCount); } - memcpy(v->frames, data + sizeof(IndexHeaderT), sizeof(FrameInfoT) * (size_t)v->frameCount); } free(data); @@ -1712,6 +1982,23 @@ static void _srtTime(int64_t milliseconds, char *out, size_t size) { } +// Tells a player's decoder thread to finish, and to give up on an index nobody will read now, and +// waits for it. Safe to call twice: the second finds no thread. +static void _stopDecoder(VideoPlayerT *v) { + // Nor can a decoder thread wait for itself, should it be the one on its way out. + if ((v->thread == NULL) || (SDL_GetThreadID(v->thread) == SDL_GetCurrentThreadID())) { + return; + } + SDL_SetAtomicInt(&v->cancelIndex, 1); + SDL_LockMutex(v->lock); + v->quitThread = true; + SDL_SignalCondition(v->wake); + SDL_UnlockMutex(v->lock); + SDL_WaitThread(v->thread, NULL); + v->thread = NULL; +} + + static int64_t _streamTimeToMs(int64_t ts, AVRational timeBase) { return av_rescale(ts, (int64_t)timeBase.num * (int64_t)MS_PER_SECOND, timeBase.den); } @@ -1724,6 +2011,8 @@ static bool _takeDecodedFrame(VideoPlayerT *v) { SDL_LockMutex(v->lock); if (v->threadError) { + // Let go first: dying stops every decoder thread, and this one cannot finish while it is held. + SDL_UnlockMutex(v->lock); utilDie("Video decoding failed: %s", v->threadErrMsg); } if (v->backReady) { @@ -1764,30 +2053,88 @@ static void _uploadFrame(VideoPlayerT *v) { const uint8_t *luma = v->front.data[0]; const uint8_t *cb = v->front.data[1]; const uint8_t *cr = v->front.data[2]; + SDL_Texture *next = NULL; - if (v->rgb) { - SDL_UpdateTexture(v->videoTexture, NULL, v->front.data[0], v->front.linesize[0]); + // A dma-buf frame goes to the GPU. Refused, its pixels were never copied, so what is showing stays. + if (v->front.prime != NULL) { + if (_uploadPrime(v)) { + v->uploadedFrame = v->front.frame; + } + v->flashed = false; + return; + } + // Shown from its dma-buf and never copied: after a flash, or a picture control, that picture returns. + if (!v->front.planes) { + if (v->primeShown != NULL) { + v->videoTexture = v->primeShown; + } + v->flashed = false; + return; + } + next = _nextTexture(v); + + if (v->blend || (v->luma != 0)) { + luma = _pictureFilter(v); + } + if (v->monochrome) { + // Both chroma planes read from one neutral plane; the buffers share a stride. + cb = _neutralChroma(v); + cr = cb; + } + if (v->nv12) { + // The neutral plane is a run of CHROMA_NEUTRAL, which reads the same interleaved. + SDL_UpdateNVTexture(next, NULL, luma, v->front.linesize[0], cb, v->front.linesize[1]); } else { - if (v->blend || (v->luma != 0)) { - luma = _pictureFilter(v); - } - if (v->monochrome) { - // Both chroma planes read from one neutral plane; the buffers share a stride. - cb = _neutralChroma(v); - cr = cb; - } - if (v->nv12) { - // The neutral plane is a run of CHROMA_NEUTRAL, which reads the same interleaved. - SDL_UpdateNVTexture(v->videoTexture, NULL, luma, v->front.linesize[0], cb, v->front.linesize[1]); - } else { - SDL_UpdateYUVTexture(v->videoTexture, NULL, luma, v->front.linesize[0], cb, v->front.linesize[1], cr, v->front.linesize[2]); - } + SDL_UpdateYUVTexture(next, NULL, luma, v->front.linesize[0], cb, v->front.linesize[1], cr, v->front.linesize[2]); } v->flashed = false; v->uploadedFrame = v->front.frame; } +// Converts the front frame from its dma-buf into the next of the player's RGBA textures. The frame is +// kept until that texture's turn comes round again, by which time the GPU has long finished reading +// it, so the decoder cannot hand its buffer out again under a draw. False when the GPU will not take +// it: zero-copy stops for this player then, and its frames are copied the old way. +static bool _uploadPrime(VideoPlayerT *v) { + RenderDmabufT frame; + const AVDRMFrameDescriptor *drm = (const AVDRMFrameDescriptor *)v->front.prime->data[0]; + int32_t slot = (v->textureIndex + 1) % VIDEO_TEXTURES; + bool ok = _describePrime(v->front.prime, &frame); + + if (!ok && (drm != NULL)) { + utilTrace("Video %d: a dma-buf frame of %d layers, the first %.4s with %d planes, is not NV12 as the GPU import takes it", v->id, drm->nb_layers, (const char *)&drm->layers[0].format, drm->layers[0].nb_planes); + } + + if (ok && (v->primeTextures[slot] == NULL)) { + v->primeTextures[slot] = renderDmabufTarget(v->renderer, v->width, v->height); + ok = v->primeTextures[slot] != NULL; + if (ok) { + SDL_SetTextureScaleMode(v->primeTextures[slot], SDL_SCALEMODE_LINEAR); + } + } + ok = ok && renderDmabufConvert(&frame, v->primeTextures[slot]); + if (!ok) { + utilTrace("Video %d: the GPU would not take a decoded frame as it is; copying frames from now on", v->id); + SDL_SetAtomicInt(&v->zeroCopy, 0); + av_frame_free(&v->front.prime); + return false; + } + if (!v->primeReported) { + v->primeReported = true; + utilTrace("Video %d: zero-copy: frames go to the GPU as dma-bufs (%dx%d, modifier 0x%016" PRIx64 ")", v->id, frame.width, frame.height, frame.modifier); + } + v->textureIndex = slot; + v->videoTexture = v->primeTextures[slot]; + v->primeShown = v->videoTexture; + av_frame_free(&v->primeHeld[slot]); + v->primeHeld[slot] = v->front.prime; + v->front.prime = NULL; + + return true; +} + + static const AVCodec *_v4l2Decoder(const AVCodec *decoder) { static const V4l2DecoderT table[] = { { AV_CODEC_ID_H263, "h263_v4l2m2m" }, @@ -1815,6 +2162,33 @@ static const AVCodec *_v4l2Decoder(const AVCodec *decoder) { } +// Until the decoder thread has built the frame table, or failed to. A failure ends the program here, +// on the main thread, as a failed decode does. +static void _waitIndexed(VideoPlayerT *v) { + if (SDL_GetAtomicInt(&v->indexState) == INDEX_PENDING) { + SDL_LockMutex(v->lock); + while (SDL_GetAtomicInt(&v->indexState) == INDEX_PENDING) { + SDL_WaitCondition(v->indexed, v->lock); + } + SDL_UnlockMutex(v->lock); + } + if (SDL_GetAtomicInt(&v->indexState) == INDEX_FAILED) { + utilDie("Video decoding failed: %s", v->threadErrMsg); + } +} + + +// Something needs the frames' pixels -- a pixel read, a flash, a picture control -- so the player +// copies them from the next frame on instead of sending them to the GPU as dma-bufs. For good: a +// game that asks once usually asks every frame. +static void _wantPlanes(VideoPlayerT *v) { + if ((SDL_GetAtomicInt(&v->zeroCopy) != 0) && (SDL_GetAtomicInt(&v->cpuWanted) == 0)) { + utilTrace("Video %d: the game reads or changes the picture's pixels; copying frames from now on", v->id); + } + SDL_SetAtomicInt(&v->cpuWanted, 1); +} + + static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const IndexHeaderT *header) { FILE *out = fopen(indexName, "wb"); @@ -1833,23 +2207,25 @@ static void _writeIndexCache(VideoPlayerT *v, const char *indexName, const Index // One white picture on the disc, the way a laserdisc player flashes when it is searched. It goes -// up at once and the next decoded frame takes it off again; a player showing colour for a script to -// read is left alone. +// up at once and the next decoded frame takes it off again. void videoFlash(int32_t playerHandle) { VideoPlayerT *v = _getPlayer(playerHandle, "videoFlash"); uint8_t *luma = NULL; uint8_t *chroma = NULL; + SDL_Texture *next = NULL; - if (v->rgb || (v->front.frame < 0)) { + if (v->front.frame < 0) { return; } + _wantPlanes(v); luma = _lumaPlane(v); chroma = _neutralChroma(v); memset(luma, FLASH_LUMA, v->lumaBytes); + next = _nextTexture(v); if (v->nv12) { - SDL_UpdateNVTexture(v->videoTexture, NULL, luma, v->front.linesize[0], chroma, v->front.linesize[1]); + SDL_UpdateNVTexture(next, NULL, luma, v->front.linesize[0], chroma, v->front.linesize[1]); } else { - SDL_UpdateYUVTexture(v->videoTexture, NULL, luma, v->front.linesize[0], chroma, v->front.linesize[1], chroma, v->front.linesize[2]); + SDL_UpdateYUVTexture(next, NULL, luma, v->front.linesize[0], chroma, v->front.linesize[1], chroma, v->front.linesize[2]); } v->flashed = true; } @@ -1927,8 +2303,13 @@ int64_t videoGetFrame(int32_t playerHandle) { } +// The one question that cannot be answered until the frame table exists, so it waits for it. int64_t videoGetFrameCount(int32_t playerHandle) { - return _getPlayer(playerHandle, "videoGetFrameCount")->frameCount; + VideoPlayerT *v = _getPlayer(playerHandle, "videoGetFrameCount"); + + _waitIndexed(v); + + return v->frameCount; } @@ -1976,53 +2357,70 @@ MIX_Mixer *videoGetMixer(void) { // Reads one pixel of the frame being shown. Returns false if there is no frame yet. bool videoGetPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *r, uint8_t *g, uint8_t *b) { - VideoPlayerT *v = _getPlayer(playerHandle, "videoGetPixel"); - const uint8_t *pixel = NULL; - int32_t c = 0; - int32_t d = 0; - int32_t e = 0; - int32_t value = 0; + VideoPlayerT *v = _getPlayer(playerHandle, "videoGetPixel"); + int32_t c = 0; + int32_t d = 0; + int32_t e = 0; + int32_t value = 0; - if ((v->front.frame < 0) || (x < 0) || (y < 0) || (x >= v->width) || (y >= v->height)) { + _wantPlanes(v); + if ((v->front.frame < 0) || !v->front.planes || (x < 0) || (y < 0) || (x >= v->width) || (y >= v->height)) { return false; } - if (v->rgb) { - pixel = v->front.data[0] + (y * v->front.linesize[0]) + (x * BYTES_PER_PIXEL); - *b = pixel[0]; - *g = pixel[1]; - *r = pixel[2]; + // BT.601 limited range, the same conversion SDL applies on the GPU for SD video. + c = v->front.data[0][y * v->front.linesize[0] + x] - 16; + if (v->nv12) { + // U and V sit side by side for each chroma sample. + d = v->front.data[1][(y / 2) * v->front.linesize[1] + (x / 2) * 2] - 128; + e = v->front.data[1][(y / 2) * v->front.linesize[1] + (x / 2) * 2 + 1] - 128; } else { - // BT.601 limited range, the same conversion SDL applies on the GPU for SD video. - c = v->front.data[0][y * v->front.linesize[0] + x] - 16; - if (v->nv12) { - // U and V sit side by side for each chroma sample. - d = v->front.data[1][(y / 2) * v->front.linesize[1] + (x / 2) * 2] - 128; - e = v->front.data[1][(y / 2) * v->front.linesize[1] + (x / 2) * 2 + 1] - 128; - } else { - d = v->front.data[1][(y / 2) * v->front.linesize[1] + (x / 2)] - 128; - e = v->front.data[2][(y / 2) * v->front.linesize[2] + (x / 2)] - 128; - } - value = (298 * c + 409 * e + 128) >> 8; - *r = (uint8_t)((value < 0) ? 0 : ((value > 255) ? 255 : value)); - value = (298 * c - 100 * d - 208 * e + 128) >> 8; - *g = (uint8_t)((value < 0) ? 0 : ((value > 255) ? 255 : value)); - value = (298 * c + 516 * d + 128) >> 8; - *b = (uint8_t)((value < 0) ? 0 : ((value > 255) ? 255 : value)); + d = v->front.data[1][(y / 2) * v->front.linesize[1] + (x / 2)] - 128; + e = v->front.data[2][(y / 2) * v->front.linesize[2] + (x / 2)] - 128; } + value = (298 * c + 409 * e + 128) >> 8; + *r = (uint8_t)((value < 0) ? 0 : ((value > 255) ? 255 : value)); + value = (298 * c - 100 * d - 208 * e + 128) >> 8; + *g = (uint8_t)((value < 0) ? 0 : ((value > 255) ? 255 : value)); + value = (298 * c + 516 * d + 128) >> 8; + *b = (uint8_t)((value < 0) ? 0 : ((value > 255) ? 255 : value)); return true; } -// Exposes the BGRA frame being shown (players loaded with rgb). Valid until the next videoUpdate of this player. +// The frame being shown, in BGRA, for a CPU draw. Converted here, once a frame, only when asked: +// asking also makes a zero-copy player copy its frames from then on, so the first ask of one can +// come back false. Valid until the next videoUpdate of this player. bool videoGetPixels(int32_t playerHandle, const uint8_t **pixels, int32_t *pitch) { VideoPlayerT *v = _getPlayer(playerHandle, "videoGetPixels"); + uint8_t *out[SCALER_PLANES]; + int32_t outPitch[SCALER_PLANES]; - if (!v->rgb || (v->front.frame < 0)) { + _wantPlanes(v); + if ((v->front.frame < 0) || !v->front.planes) { return false; } - *pixels = v->front.data[0]; - *pitch = v->front.linesize[0]; + if (v->pixels == NULL) { + v->pixelsPitch = FFALIGN(v->width * BYTES_PER_PIXEL, STRIDE_ALIGNMENT); + v->pixels = av_malloc((size_t)v->pixelsPitch * (size_t)v->height); + if (v->pixels == NULL) { + utilDie("Unable to allocate video pixels."); + } + } + if (v->pixelsFrame != v->front.frame) { + v->pixelsSws = sws_getCachedContext(v->pixelsSws, v->width, v->height, v->nv12 ? AV_PIX_FMT_NV12 : AV_PIX_FMT_YUV420P, v->width, v->height, AV_PIX_FMT_BGRA, SWS_BILINEAR, NULL, NULL, NULL); + if (v->pixelsSws == NULL) { + utilDie("Unable to convert video frames for drawing."); + } + memset(out, 0, sizeof(out)); + memset(outPitch, 0, sizeof(outPitch)); + out[0] = v->pixels; + outPitch[0] = v->pixelsPitch; + sws_scale(v->pixelsSws, (const uint8_t *const *)v->front.data, v->front.linesize, 0, v->height, out, outPitch); + v->pixelsFrame = v->front.frame; + } + *pixels = v->pixels; + *pitch = v->pixelsPitch; return true; } @@ -2104,12 +2502,13 @@ int32_t videoGetWidth(int32_t playerHandle) { } -// Reads the raw 4:2:0 samples of one pixel of the frame being shown. False for rgb players, before -// the first frame, or outside the frame. +// Reads the raw 4:2:0 samples of one pixel of the frame being shown. False before the first frame, +// or outside the frame. bool videoGetYUVPixel(int32_t playerHandle, int32_t x, int32_t y, uint8_t *luma, uint8_t *cb, uint8_t *cr) { VideoPlayerT *v = _getPlayer(playerHandle, "videoGetYUVPixel"); - if (v->rgb || (v->front.frame < 0) || (x < 0) || (y < 0) || (x >= v->width) || (y >= v->height)) { + _wantPlanes(v); + if ((v->front.frame < 0) || !v->front.planes || (x < 0) || (y < 0) || (x >= v->width) || (y >= v->height)) { return false; } *luma = v->front.data[0][y * v->front.linesize[0] + x]; @@ -2169,11 +2568,12 @@ bool videoIsPlaying(int32_t playerHandle) { } -// audioFilename may be NULL when the audio lives in the video file. rgb players decode to BGRA so -// scripts can read the pixels; everything else stays YUV and is converted by the GPU. -int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb) { +// audioFilename may be NULL when the audio lives in the video file. Every player's frames stay YUV +// and the GPU converts them; videoGetPixels makes BGRA only when something asks. +int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool background) { VideoPlayerT *v = NULL; const char *name = audioFilename ? audioFilename : videoFilename; + int32_t x = 0; // Create new videoPlayer v = calloc(1, sizeof(VideoPlayerT)); @@ -2190,7 +2590,7 @@ int32_t videoLoad(const char *videoFilename, const char *audioFilename, const ch v->videoStream = -1; v->volumeLeft = VIDEO_VOLUME_MAX; v->volumeRight = VIDEO_VOLUME_MAX; - v->rgb = rgb; + v->pixelsFrame = -1; v->requestedFrame = -1; v->pendingFrame = -1; v->uploadedFrame = -1; @@ -2199,27 +2599,44 @@ int32_t videoLoad(const char *videoFilename, const char *audioFilename, const ch // Video: demuxer, frame table, decoder. _openVideo(v, videoFilename); - // A hardware decoder hands its pictures back as NV12. Keeping them that way the whole distance - // spends no CPU on a colour conversion that the GPU does anyway when it samples the texture; the - // V4L2 path is left alone because what its drivers hand over varies. - v->nv12 = (!rgb) && (v->hwDevice != NULL); - _buildFrameTable(v, videoFilename, indexPath); - _reportKeyframes(v, videoFilename); - - // Create video texture - v->videoTexture = SDL_CreateTexture(renderer, rgb ? SDL_PIXELFORMAT_BGRA32 : (v->nv12 ? SDL_PIXELFORMAT_NV12 : SDL_PIXELFORMAT_IYUV), SDL_TEXTUREACCESS_STREAMING, v->width, v->height); - if (v->videoTexture == NULL) { - utilDie("%s", SDL_GetError()); + // A hardware decoder hands its pictures back as NV12, MPP's included. Keeping them that way the + // whole distance spends no CPU on a colour conversion that the GPU does anyway when it samples the + // texture; the V4L2 path is left alone because what its drivers hand over varies. + v->nv12 = (v->hwDevice != NULL) || v->mpp; + // The frame table. In the background the decoder thread builds it as its first job; otherwise + // here, since the disc's is wanted the moment the engine starts. + if (background) { + v->indexPath = (indexPath != NULL) ? strdup(indexPath) : NULL; + SDL_SetAtomicInt(&v->indexState, INDEX_PENDING); + } else { + if (!_buildFrameTable(v, videoFilename, indexPath)) { + utilDie("%s", v->threadErrMsg); + } + _reportKeyframes(v, videoFilename); + SDL_SetAtomicInt(&v->indexState, INDEX_READY); } - // Video textures scale smoothly. - SDL_SetTextureScaleMode(v->videoTexture, SDL_SCALEMODE_LINEAR); + + // Create the video textures, which scale smoothly + for (x = 0; x < VIDEO_TEXTURES; x++) { + v->videoTextures[x] = SDL_CreateTexture(renderer, v->nv12 ? SDL_PIXELFORMAT_NV12 : SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, v->width, v->height); + if (v->videoTextures[x] == NULL) { + utilDie("%s", SDL_GetError()); + } + SDL_SetTextureScaleMode(v->videoTextures[x], SDL_SCALEMODE_LINEAR); + } + v->videoTexture = v->videoTextures[0]; + // Zero-copy where the decoder hands out dma-bufs and the GPU takes them. A player whose pixels are + // asked for later copies its frames from then on (_wantPlanes). + v->renderer = renderer; + SDL_SetAtomicInt(&v->zeroCopy, (_zeroCopyWanted && (v->mpp || (v->hwDevice != NULL)) && renderDmabufAvailable()) ? 1 : 0); // Frame buffers and the decoder thread that fills them _allocateFrameBuffer(v, &v->front); _allocateFrameBuffer(v, &v->back); - v->lock = SDL_CreateMutex(); - v->wake = SDL_CreateCondition(); - if (!v->lock || !v->wake) { + v->lock = SDL_CreateMutex(); + v->wake = SDL_CreateCondition(); + v->indexed = SDL_CreateCondition(); + if (!v->lock || !v->wake || !v->indexed) { utilDie("%s", SDL_GetError()); } v->thread = SDL_CreateThread(_decoderThread, "singeDecoder", v); @@ -2431,7 +2848,16 @@ bool videoReopenAudio(int32_t playerHandle, const char *audioFilename) { void videoSeek(int32_t playerHandle, int64_t seekFrame) { VideoPlayerT *v = _getPlayer(playerHandle, "videoSeek"); - int64_t count = v->frameCount; + int64_t count = 0; + + // Still indexing: remembered as asked, and put into range once the table says what range is. + if (SDL_GetAtomicInt(&v->indexState) != INDEX_READY) { + v->frame = seekFrame; + v->resetTime = true; + v->indexApplied = false; + return; + } + count = v->frameCount; // Wrap into range. if (count > 0) { @@ -2475,8 +2901,8 @@ void videoSetAudioTrack(int32_t playerHandle, int32_t track) { void videoSetBlend(int32_t playerHandle, bool enabled) { VideoPlayerT *v = _getPlayer(playerHandle, "videoSetBlend"); - if (v->rgb) { - return; + if (enabled) { + _wantPlanes(v); } v->blend = enabled; if (v->front.frame >= 0) { @@ -2503,24 +2929,27 @@ void videoSetHardwareDecoding(bool enabled) { void videoSetLuma(int32_t playerHandle, bool enabled, int32_t level) { VideoPlayerT *v = _getPlayer(playerHandle, "videoSetLuma"); - if (v->rgb) { - return; - } v->luma = enabled ? SDL_clamp(level, 0, LUMA_LEVEL_MAX) - LUMA_LEVEL_NEUTRAL : 0; + if (v->luma != 0) { + _wantPlanes(v); + } if (v->front.frame >= 0) { _uploadFrame(v); } } -// Shows a YUV player in luma only. The frame on the texture is re-uploaded so a paused video -// changes at once. Pixel reads keep returning colour; rgb players are unaffected. +// Shows a player in luma only. The frame on the texture is re-uploaded so a paused video changes at +// once. Pixel reads keep returning colour. void videoSetMonochrome(int32_t playerHandle, bool enabled) { VideoPlayerT *v = _getPlayer(playerHandle, "videoSetMonochrome"); - if (v->rgb || (enabled == v->monochrome)) { + if (enabled == v->monochrome) { return; } + if (enabled) { + _wantPlanes(v); + } v->monochrome = enabled; if (v->front.frame >= 0) { _uploadFrame(v); @@ -2542,23 +2971,57 @@ void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPerc } +// Chosen before any video loads, like hardware decoding; existing players keep what they started with. +void videoSetZeroCopy(bool enabled) { + _zeroCopyWanted = enabled; +} + + +// Every player's decoding stopped, for a program on its way out through utilDie: exit() runs the MPP +// library's teardown, and anything still running inside MPP crashes there, burying the reason the +// program was dying. Stopping our decoder threads is not enough, since MPP keeps threads of its own +// until the decoder is closed -- and it is not closed while frames zero-copy holds still point into +// it -- so those frames go, and then the decoder. Nothing else is freed; the process is ending. +void videoStopDecoders(void) { + VideoPlayerT *v = NULL; + VideoPlayerT *t = NULL; + int32_t x = 0; + + HASH_ITER(hh, _videoPlayerHash, v, t) { + _stopDecoder(v); + if (v->thread != NULL) { + // This is that decoder's own thread dying: its codec is in use right here. + continue; + } + for (x = 0; x < VIDEO_TEXTURES; x++) { + av_frame_free(&v->primeHeld[x]); + } + av_frame_free(&v->front.prime); + av_frame_free(&v->back.prime); + av_frame_free(&v->videoFrame); + av_frame_free(&v->hwFrame); + avcodec_free_context(&v->videoCodec); + } +} + + void videoUnload(int32_t playerHandle) { VideoPlayerT *v = _getPlayer(playerHandle, "videoUnload"); int32_t x = 0; // Stop the decoder before touching anything it uses. - SDL_LockMutex(v->lock); - v->quitThread = true; - SDL_SignalCondition(v->wake); - SDL_UnlockMutex(v->lock); - SDL_WaitThread(v->thread, NULL); + _stopDecoder(v); SDL_DestroyCondition(v->wake); + SDL_DestroyCondition(v->indexed); + free(v->indexPath); SDL_DestroyMutex(v->lock); for (x = 0; x < PLANE_COUNT; x++) { av_free(v->front.data[x]); av_free(v->back.data[x]); } sws_freeContext(v->sws); + sws_freeContext(v->pixelsSws); + av_free(v->pixels); _deinterlaceClose(v); av_frame_free(&v->filterFrame); av_frame_free(&v->hwFrame); @@ -2571,7 +3034,13 @@ void videoUnload(int32_t playerHandle) { free(v->filename); free(v->neutralChroma); free(v->lumaPlane); - SDL_DestroyTexture(v->videoTexture); + for (x = 0; x < VIDEO_TEXTURES; x++) { + SDL_DestroyTexture(v->videoTextures[x]); + renderDmabufTargetDestroy(v->primeTextures[x]); + av_frame_free(&v->primeHeld[x]); + } + av_frame_free(&v->front.prime); + av_frame_free(&v->back.prime); _audioClose(v); #pragma GCC diagnostic push @@ -2592,9 +3061,26 @@ int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) { VideoPlayerT *v = _getPlayer(playerHandle, "videoUpdate"); uint64_t now = utilTicks(); int64_t elapsed = 0; - int64_t count = v->frameCount; + int64_t count = 0; int64_t next = 0; int64_t lastDuration = 0; + int32_t state = SDL_GetAtomicInt(&v->indexState); + + // Held where it is, picture and sound, until the frame table is built. The clock starts when it + // is, so the video begins from its first frame (or wherever it was sent) rather than part way in. + if (state != INDEX_READY) { + if (state == INDEX_FAILED) { + utilDie("Video decoding failed: %s", v->threadErrMsg); + } + *texture = v->videoTexture; + return v->frame; + } + if (!v->indexApplied) { + v->indexApplied = true; + v->frame = ((v->frame % v->frameCount) + v->frameCount) % v->frameCount; + v->resetTime = true; + } + count = v->frameCount; if (v->resetTime) { _resetClock(v, now); diff --git a/src/videoPlayer.h b/src/videoPlayer.h index f8893e194..1cdabe429 100644 --- a/src/videoPlayer.h +++ b/src/videoPlayer.h @@ -78,7 +78,9 @@ void videoInit(MIX_Mixer *mixer); // What every video opened from now on does about interlaced pictures. void videoSetDeinterlace(DeinterlaceE mode); bool videoIsPlaying(int32_t playerHandle); -int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool rgb); +// background builds the frame table on the decoder thread: the video holds still until it is built, +// a seek meanwhile is kept and applied then, and only videoGetFrameCount waits for it. +int32_t videoLoad(const char *videoFilename, const char *audioFilename, const char *indexPath, SDL_Renderer *renderer, bool background); void videoLockAudio(void); void videoPause(int32_t playerHandle); void videoPlay(int32_t playerHandle); @@ -93,6 +95,8 @@ void videoSetHardwareDecoding(bool enabled); void videoSetLuma(int32_t playerHandle, bool enabled, int32_t level); void videoSetMonochrome(int32_t playerHandle, bool enabled); void videoSetVolume(int32_t playerHandle, int32_t leftPercent, int32_t rightPercent); +void videoSetZeroCopy(bool enabled); +void videoStopDecoders(void); void videoUnload(int32_t playerHandle); void videoUnlockAudio(void); int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture); diff --git a/testScripts/Models/DragonModel.glb b/testScripts/Models/DragonModel.glb index fdc59fa3b..2e006277a 100644 --- a/testScripts/Models/DragonModel.glb +++ b/testScripts/Models/DragonModel.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4133c9338754ec3e4e04a45d335b018f07b8f61a94c840622051108332561538 -size 131996 +oid sha256:5771565216665018104eed6c4d859962e59dc840ecd2b90ac64d7119a96f1311 +size 134240 diff --git a/testScripts/menuIntro.singe b/testScripts/menuIntro.singe index 238cae879..3bbcaa522 100644 --- a/testScripts/menuIntro.singe +++ b/testScripts/menuIntro.singe @@ -60,7 +60,7 @@ local LOGO_Y = 2.5 -- Where the logo settles, so it sits on the horiz -- rather than its shoulder. The joints are baked into the model now; coordinates below are the -- model's own: it faces +X, +Y is up, and the wings reach out along +/-Z. local MOUTH = { 3.55, 5.60, 0.00 } -- Where the flame leaves, in model coordinates. -local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see assets/DragonPivots.json. +local HEAD_AT = { 0.62, 5.69, 0.00 } -- The head's baked joint; see media/DragonPivots.json. local FLAP_MAX = 34.0 -- Degrees a wing rises and falls. local FLAP_FAST = 13.0 -- Radians a second while it is flying out of the blast. local FLAP_SLOW = 3.4 -- and once it has settled. @@ -95,7 +95,7 @@ sceneSetTonemap(TONEMAP_ACES) -- The dragon's parts carry their own joints now: util/objToGlb.py bakes them from --- assets/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without +-- media/DragonPivots.json, so wingL turns at the shoulder and head at the top of the neck without -- anything being rigged here. This used to build them at run time from bounding boxes, which -- worked but left every future use of the model to re-derive the same thing. local function joint(root, name, parent) @@ -219,11 +219,13 @@ nodeSetScale(dragon, 0.9) nodeSetScale(logo, 0.5) nodeSetVisible(logo, false) --- The rig. The head hangs off the neck so that rearing the neck carries the head with it. +-- The rig. The head hangs off the neck so that rearing the neck carries the head with it, and +-- the jaw off the head for the same reason. Tip first: joint measures the parent where it sits, +-- so the jaw has to be hung on the head before the head is moved to be relative to the neck. local wingLeft = joint(dragon, "wingL") local wingRite = joint(dragon, "wingR") local neck = joint(dragon, "neck") --- The head hangs off the neck so that rearing the neck carries the head with it. +joint(dragon, "jaw", nodeFind("head", dragon)) local head = joint(dragon, "head", neck) -- The flame, on a node at the dragon's mouth so it follows the head round. diff --git a/testScripts/ports/compare.sh b/testScripts/ports/compare.sh index b2532250c..22169bda6 100755 --- a/testScripts/ports/compare.sh +++ b/testScripts/ports/compare.sh @@ -8,7 +8,7 @@ # SINGE_LIBRARY the test library (default: ~/claude/singetest/ported, where the # ported originals live; the hypseus families look under its hypseus/) # SINGE_RUN a directory to run in, with Singe/ and Forge/ beside each other -# (default: a fresh one under /tmp, with assets/Forge copied in) +# (default: a fresh one under /tmp, with forge/ copied in) # # Families: actionMax (games 38AmbushAlley, BlueThunder, Hydrosub2021, PopsGhostly, SonicFury); # karis (a game as directory/script, e.g. TimeGal/Timegal.singe); @@ -34,7 +34,7 @@ fi # is what the port plays. mkdir -p "$run" rm -rf "$run/Forge" -cp -r "$repo/assets/Forge" "$run/Forge" +cp -r "$repo/forge" "$run/Forge" if [[ -z "$binary" || ! -x "$binary" ]]; then echo "compare.sh: no engine binary; set SINGE" >&2 exit 2 @@ -75,7 +75,7 @@ case "$family" in run=$run/$game mkdir -p "$run" rm -rf "$run/Forge" - cp -r "$repo/assets/Forge" "$run/Forge" + cp -r "$repo/forge" "$run/Forge" ln -sfn "$library/$game/singe" "$run/singe" ;; rdg) @@ -90,7 +90,7 @@ case "$family" in run=$run/$game mkdir -p "$run" rm -rf "$run/Forge" - cp -r "$repo/assets/Forge" "$run/Forge" + cp -r "$repo/forge" "$run/Forge" ln -sfn "$library/$game/singe" "$run/singe" ;; alg) @@ -119,7 +119,7 @@ case "$family" in run=$run/$game mkdir -p "$run" rm -rf "$run/Forge" - cp -r "$repo/assets/Forge" "$run/Forge" + cp -r "$repo/forge" "$run/Forge" ln -sfn "$library/$game/singe" "$run/singe" ;; videoland|timetraveler) @@ -134,7 +134,7 @@ case "$family" in run=$run/$game mkdir -p "$run" rm -rf "$run/Forge" - cp -r "$repo/assets/Forge" "$run/Forge" + cp -r "$repo/forge" "$run/Forge" ;; *) echo "compare.sh: no family called $family" >&2 diff --git a/testScripts/ports/standalone.txt b/testScripts/ports/standalone.txt new file mode 100644 index 000000000..7331b23dd --- /dev/null +++ b/testScripts/ports/standalone.txt @@ -0,0 +1,22 @@ +# Titles whose Forge port plays from its description alone, proved by +# util/forgeStandalone.py. util/forgePorts.py leaves the game's own Lua +# out of these ports. Regenerate the list; do not edit it by hand. +AlteredCarbon +Arcade_Xperience_Vol1 +Asterix +ChantzesStone +CliffHanger +DragonsLair2Enhanced +FireAndIce +Linea +NinjaHayateHD +OeilPourOeil +Sonic_the_Hedgehog_1996 +SuckerPunch +Survival +TheElderScrolls +TimeGal +TitanAE +Tron +badlands_lite +mazinga diff --git a/testScripts/ports/sweep.sh b/testScripts/ports/sweep.sh index ee7ac50be..abff6a424 100755 --- a/testScripts/ports/sweep.sh +++ b/testScripts/ports/sweep.sh @@ -26,6 +26,39 @@ pair() { out=$(SINGE_RUN=$run/${game//\//_} SINGE_LIBRARY=$lib "$repo/testScripts/ports/compare.sh" "$family" "$game" 2>&1 | grep -a -v "hwaccel\|XIO" | grep -a "plays as\|differ\|no trace\|compare.sh" | tail -1) echo "${out:-$family $game: no result}" } +# compare.sh's family for a description, from its kind: the drivers differ by family, and a +# description run under another family's driver produces a trace of one line. +familyOf() { + local desc=$1 + + if grep -a -q 'kind = "rdg"\|kind = "timegal"' "$desc"; then + echo rdg + elif grep -a -q 'kind = "alg"' "$desc"; then + echo alg + elif grep -a -q 'kind = "timetraveler"' "$desc"; then + echo timetraveler + elif grep -a -q 'kind = "videoland"' "$desc"; then + echo videoland + else + echo "$2" + fi +} +# One description of the library: the families that take a game by its folder get the folder, +# the rest the script beside the description. +libraryPair() { + local desc=$1 script=$2 lib=$3 folder=$4 + local family + + family=$(familyOf "$desc" karis) + case "$family" in + alg|timetraveler|videoland) + pair "$family" "$folder" "$lib" + ;; + *) + pair "$family" "$script" "$lib" + ;; + esac +} # Two at a time: three engines have run the machine out of memory. throttle() { while (( $(jobs -rp | wc -l) >= 2 )); do @@ -40,7 +73,7 @@ for desc in "$library"/ported/*/*.forge; do folder=$(basename "$(dirname "$desc")") [[ "$folder" == "ActionMax" || "$folder" == "hypseus" ]] && continue throttle - pair karis "$folder/$(basename "${desc%.forge}").singe" "$library/ported" & + libraryPair "$desc" "$folder/$(basename "${desc%.forge}").singe" "$library/ported" "$folder" & done # The map-mode games keep their script in Script/ (in ported/ once they agree, in the library # root until then). @@ -49,19 +82,14 @@ for lib in "$library/ported" "$library"; do [[ -f "$desc" ]] || continue folder=$(basename "$(dirname "$(dirname "$desc")")") throttle - pair karis "$folder/Script/$(basename "${desc%.forge}").singe" "$lib" & + libraryPair "$desc" "$folder/Script/$(basename "${desc%.forge}").singe" "$lib" "$folder" & done done for title in "$library"/ported/hypseus/*/; do title=$(basename "$title") desc=$(find -L "$library/ported/hypseus/$title/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) [[ -n "$desc" ]] || continue - family=hypseus - if grep -a -q 'kind = "rdg"\|kind = "timegal"' "$desc"; then - family=rdg - elif grep -a -q 'kind = "alg"' "$desc"; then - family=alg - fi + family=$(familyOf "$desc" hypseus) throttle pair "$family" "$title" "$library/ported/hypseus" & done diff --git a/thirdparty/ffmpeg/libavcodec/rkmppdec.c b/thirdparty/ffmpeg/libavcodec/rkmppdec.c index a6bc56577..c192533fc 100644 --- a/thirdparty/ffmpeg/libavcodec/rkmppdec.c +++ b/thirdparty/ffmpeg/libavcodec/rkmppdec.c @@ -219,7 +219,13 @@ static av_cold int rkmpp_init_decoder(AVCodecContext *avctx) goto fail; } - ret = mpp_buffer_group_get_internal(&decoder->frame_group, MPP_BUFFER_TYPE_ION); + /* Singe: cachable DRM buffers first. ION's are uncached, and a program that reads frames back + * with av_hwframe_transfer_data then crawls through them at uncached speed -- 28 ms for one + * 1440x1080 frame on an RK3326. hwcontext_drm brackets its mapping with DMA_BUF_IOCTL_SYNC, so + * the CPU sees what the decoder wrote. ION remains the fallback where DRM cannot allocate. */ + ret = mpp_buffer_group_get_internal(&decoder->frame_group, MPP_BUFFER_TYPE_DRM | MPP_BUFFER_FLAGS_CACHABLE); + if (ret) + ret = mpp_buffer_group_get_internal(&decoder->frame_group, MPP_BUFFER_TYPE_ION); if (ret) { av_log(avctx, AV_LOG_ERROR, "Failed to retrieve buffer group (code = %d)\n", ret); ret = AVERROR_UNKNOWN; diff --git a/util/dragonModel.py b/util/dragonModel.py index d8f842096..3cfa6af92 100644 --- a/util/dragonModel.py +++ b/util/dragonModel.py @@ -1,4 +1,4 @@ -# Builds assets/DragonModel.obj (+ .mtl): a solid, three-dimensional dragon whose side view is exactly +# Builds media/DragonModel.obj (+ .mtl): a solid, three-dimensional dragon whose side view is exactly # the tangram picture. The tiles of Dragon.jpeg are grouped into body parts; each part gets its own # thickness, with the tiles as chamfered slabs on both faces and a bulging faceted core between them. # Legs and horns are mirrored to both sides, and the wings are swept out from the shoulders. @@ -12,9 +12,9 @@ # --relief (with --print) keeps only the top half, lying flat: every part's core runs down to Z = 0 # so the dragon can sit on a plate (see util/plaqueModel.py). # -# Usage: python3 util/dragonModel.py pieces.json assets/DragonModel.obj [--scale 0.01] -# python3 util/dragonModel.py pieces.json assets/DragonPrint.obj --print --stl assets --width 150 --check -# pieces.json comes from: python3 util/traceDragon.py assets/Dragon.jpeg assets/Dragon.svg --pieces pieces.json +# Usage: python3 util/dragonModel.py pieces.json media/DragonModel.obj [--scale 0.01] +# python3 util/dragonModel.py pieces.json media/DragonPrint.obj --print --stl media --width 150 --check +# pieces.json comes from: python3 util/traceDragon.py media/Dragon.jpeg media/Dragon.svg --pieces pieces.json import argparse import json import os @@ -34,6 +34,8 @@ CELL_TOLERANCE = 1.0 # polygon simplification for grown tiles in the print CELL_GAP = 0.6 # inset of each grown tile so neighbouring colour bodies never overlap PLATE_DEPTH = 7.0 # half thickness of printable wings, horns and crest BODY_DEPTH = 55.0 +JAW_DEPTH = 34.0 # half thickness of the lower jaw, a little inside the head's 42 so it + # reads as hinged within the head rather than beside it MATERIAL_ORDER = ('dark', 'champagne', 'glass', 'frame', 'plate') @@ -240,10 +242,18 @@ class DragonT: def parts(self): # (name, tiles, zCentre, depth, plate?) for every part; relief keeps only the +Z side. sides = ((1.0, 'R'),) if self.relief else ((-1.0, 'L'), (1.0, 'R')) - found = [('head', self.tilesNear([(800, 130), (874, 171), (968, 182), (856, 157), (838, 243), (917, 247)]), 0.0, 42.0, False), + # The lower jaw is its own part in the display model so the menu intro can open the mouth; + # the printable variants keep it in the head, which prints as one piece. + headAnchors = [(800, 130), (874, 171), (968, 182), (856, 157), (838, 243)] + jawAnchors = [(917, 247)] + if self.printable: + headAnchors += jawAnchors + found = [('head', self.tilesNear(headAnchors), 0.0, 42.0, False), ('neck', self.tilesNear([(689, 232), (767, 233), (768, 313), (749, 383), (848, 408)]), 0.0, 36.0, False), ('body', self.tilesNear([(766, 509), (566, 492), (664, 549), (905, 511), (859, 542), (786, 638), (881, 618)]), 0.0, BODY_DEPTH, False), ('tail', self.tilesNear([(577, 730), (494, 705), (365, 713), (260, 647), (225, 558), (291, 477)]), 0.0, 26.0, False)] + if not self.printable: + found.append(('jaw', self.tilesNear(jawAnchors), 0.0, JAW_DEPTH, False)) for side, label in sides: found.append(('hindLeg' + label, self.tilesNear([(934, 702), (885, 722), (1001, 727)]), side * (BODY_DEPTH - 26.0), 26.0, False)) found.append(('foreLeg' + label, self.tilesNear([(406, 589), (479, 578)]), side * 27.0, 22.0, False)) diff --git a/util/dragonObj.py b/util/dragonObj.py index da4f43caa..db2f83d75 100644 --- a/util/dragonObj.py +++ b/util/dragonObj.py @@ -1,8 +1,8 @@ -# Builds assets/Dragon.obj (+ .mtl) from the tile polygons that util/traceDragon.py exports: every +# Builds media/Dragon.obj (+ .mtl) from the tile polygons that util/traceDragon.py exports: every # tile becomes a slab with a chamfered front edge, standing upright in the XY plane with depth along +Z, # resting on Y = 0 and centred on X = 0. Planar UVs run across the whole figure so a rendering of # Dragon.svg can be used as a texture. -# Usage: python3 util/dragonObj.py pieces.json assets/Dragon.obj [--scale 0.01] [--depth 10] [--bevel 3] +# Usage: python3 util/dragonObj.py pieces.json media/Dragon.obj [--scale 0.01] [--depth 10] [--bevel 3] import argparse import json import os diff --git a/util/forgeKit.py b/util/forgeKit.py index 7c5bae84f..1b51e93e9 100644 --- a/util/forgeKit.py +++ b/util/forgeKit.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # Makes the Forge tutorial kit -- the pictures, sounds, music, clip, and model the tutorials in -# docs/Forge.adoc tell the reader to use -- into assets/Forge/kit/, which the build packs into +# docs/Forge.adoc tell the reader to use -- into forge/kit/, which the build packs into # Forge.game. Everything here is drawn or synthesised by this script, so the kit can be remade # whenever a tutorial wants something changed: # @@ -19,7 +19,7 @@ import wave from PIL import Image, ImageDraw -KIT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "Forge", "kit") +KIT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "forge", "kit") MODELS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "testScripts", "Models") RATE = 22050 diff --git a/util/forgePortKaris.lua b/util/forgePortKaris.lua index 72085ab23..f530b351e 100644 --- a/util/forgePortKaris.lua +++ b/util/forgePortKaris.lua @@ -219,20 +219,6 @@ end env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir assert(loadfile(dir .. "/" .. main, "t", env))() --- The add-ons too, when the game has them: they carry the hooks the loop calls, and a hook that --- is a function of the state can be read here rather than run at play (see swaps below). A file --- that will not load under the stubs is no loss; the game still names it and the loop runs it. -local addonsFile = dir .. "/Script/addons.singe" -local addonsOk = false - -do - local chunk = loadfile(addonsFile, "t", env) - - if chunk then - addonsOk = pcall(chunk) - end -end - -- ===== The settings =========================================================================== -- The dips, by name, from what the game was last saved with or shipped with: the framework @@ -672,6 +658,20 @@ local function algLevels() return chunk and chunk() or nil end +-- The add-ons, last of all: they carry the hooks the loop calls, and a hook that is a function +-- of the state can be read here rather than run at play (see swaps). They are loaded after the +-- settings are taken, because their own load-time globals (a sound handle, a map cursor) belong +-- to them and not to the game's declarations -- a snapshot that carried them would describe a +-- state the game never starts in. A file that will not load under the stubs is no loss; the +-- game still names it and the loop still runs it. +do + local chunk = loadfile(dir .. "/Script/addons.singe", "t", env) + + if chunk then + pcall(chunk) + end +end + local qte = { script = main, addons = (not rdg) and "Script/addons.singe" or nil, diff --git a/util/forgePorts.py b/util/forgePorts.py index fb1a43322..d3fb75f1f 100644 --- a/util/forgePorts.py +++ b/util/forgePorts.py @@ -301,7 +301,7 @@ def main(): # The compiler is Forge's, so the engine runs it: every description built into its folder, # from a directory with Forge beside Singe, never the repository. run = tempfile.mkdtemp(prefix="forgePorts.") - shutil.copytree(os.path.join(REPO, "assets", "Forge"), os.path.join(run, "Forge")) + shutil.copytree(os.path.join(REPO, "forge"), os.path.join(run, "Forge")) with open(os.path.join(run, "build.singe"), "w") as script: script.write('dofile("Forge/AuthorCompile.singe")\n') for description, port in builds: diff --git a/util/forgeStandalone.py b/util/forgeStandalone.py index 07cf5b3b0..ac62cd88b 100644 --- a/util/forgeStandalone.py +++ b/util/forgeStandalone.py @@ -76,9 +76,14 @@ def unhost(descriptions): out.writelines(kept) -def compare(family, game, library): +def compare(family, game, library, run=None): + # One run directory, emptied between titles: compare.sh makes a fresh one under /tmp for every + # call otherwise, and a sweep of the library leaves a hundred of them behind. + if run: + shutil.rmtree(run, ignore_errors=True) + os.makedirs(run, exist_ok=True) done = subprocess.run([os.path.join(REPO, "testScripts", "ports", "compare.sh"), family, game], - cwd=REPO, env=dict(os.environ, SINGE_LIBRARY=library), + cwd=REPO, env=dict(os.environ, SINGE_LIBRARY=library, **({"SINGE_RUN": run} if run else {})), capture_output=True, text=True, timeout=1800) out = done.stdout + done.stderr if "plays as the original does" in out: @@ -97,12 +102,15 @@ def main(): if not rows: sys.exit("forgeStandalone: no titles to test") work = tempfile.mkdtemp(prefix="forgeStandalone.") + run = os.path.join(work, "run") passed = [] for title, family, game, path in rows: if hosted: - # As it ships: the library itself, nothing copied, nothing stripped. - ok, saying = compare(family, game, LIBRARY) + # As it ships: the library itself, nothing copied, nothing stripped. A Hypseus + # title's library is the hypseus folder inside it, which is what compare.sh looks + # under; handing it the root leaves it unable to find the video. + ok, saying = compare(family, game, os.path.join(LIBRARY, "hypseus") if family == "hypseus" else LIBRARY, run) print("%-28s %s" % (title, "agrees" if ok else "DIFFERS"), flush=True) print(" %s" % saying, flush=True) if ok: @@ -117,7 +125,7 @@ def main(): copied = strip(os.path.join(LIBRARY, title), library) inside = [os.path.join(r, f) for r, _, fs in os.walk(copied) for f in fs if f.endswith(".forge")] unhost(inside) - ok, saying = compare(family, game, library) + ok, saying = compare(family, game, library, run) print("%-28s %s" % (title, "STANDS ALONE" if ok else "needs its Lua"), flush=True) print(" %s" % saying, flush=True) if ok: @@ -132,9 +140,11 @@ def main(): out.write("# Titles whose Forge port plays from its description alone, proved by\n") out.write("# util/forgeStandalone.py. util/forgePorts.py leaves the game's own Lua\n") out.write("# out of these ports. Regenerate the list; do not edit it by hand.\n") - for title in sorted(passed): + # A folder with two games in it (Chantze's Stone, Fire and Ice's two cuts) is tested + # once per description and named once here. + for title in sorted(set(passed)): out.write(title + "\n") - print("\n%d of %d stand alone; written to %s" % (len(passed), len(rows), LIST)) + print("\n%d of %d stand alone, %d titles; written to %s" % (len(passed), len(rows), len(set(passed)), LIST)) else: print("\n%d of %d stand alone (the list is only written by a full run)" % (len(passed), len(rows))) diff --git a/util/forgeVocabulary.lua b/util/forgeVocabulary.lua index 7dff2e8a4..558d61e68 100644 --- a/util/forgeVocabulary.lua +++ b/util/forgeVocabulary.lua @@ -1,5 +1,5 @@ -- Writes the Forge vocabulary -- every layer, look, behaviour, event, condition, and action the --- manifest in assets/Forge/Author.singe declares -- as AsciiDoc, for docs/Forge.adoc to include. +-- manifest in forge/Author.singe declares -- as AsciiDoc, for docs/Forge.adoc to include. -- The manifest is the one source of truth; this keeps the manual from drifting from it. -- -- lua5.4 util/forgeVocabulary.lua > docs/ForgeVocabulary.adoc @@ -59,9 +59,9 @@ local function section(title, set, extra) end -dofile("assets/Forge/Author.singe") +dofile("forge/Author.singe") -print("// Generated by util/forgeVocabulary.lua from assets/Forge/Author.singe. Do not edit.") +print("// Generated by util/forgeVocabulary.lua from forge/Author.singe. Do not edit.") print("") section("Layers", AUTHOR.layers) section("Looks", AUTHOR.looks) diff --git a/util/makeMenuSound.py b/util/makeMenuSound.py index db71b4ce2..bc29c745f 100644 --- a/util/makeMenuSound.py +++ b/util/makeMenuSound.py @@ -2,7 +2,7 @@ # as the menu takes the screen. The three things the picture shows -- the charge going off, the # logo bursting out of it, the dragon rearing and breathing fire -- are recordings, three sounds # from soundbible.com under the Creative Commons Attribution 3.0 licence, kept as FLAC under -# assets/samples and credited in LICENSES: Mark DiAngelo's "Explosion Ultra Bass", Mike Koenig's +# media/samples and credited in LICENSES: Mark DiAngelo's "Explosion Ultra Bass", Mike Koenig's # "Large Fireball", and Daniel Simon's "Dragon Fire Breath and Roar". They replaced the blast and # the flame this script used to synthesise, which never sounded like the things themselves. What # is between them -- the wingbeats, the riser as the grid comes up, the logo leaving, and the bed @@ -72,7 +72,7 @@ ROOM_DECAY = 0.85 # Seconds the room falls by a factor of e. ROOM_DARK = 1500 # It loses its top as it goes, the way a room does, and a long way off # only the bottom of a blast is left at all. ROOM_PREDELAY = 0.014 # The dry sound is heard on its own first, or it arrives already blurred. -SAMPLES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "samples") +SAMPLES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "media", "samples") EXPLOSION_LEVEL = 1.0 # The recordings against each other: the charge fills the scale, the DRAGON_LEVEL = 1.2 # dragon is driven into the limiter over it, the fireball is a rush FIREBALL_LEVEL = 0.6 # rather than a blow. diff --git a/util/plaqueModel.py b/util/plaqueModel.py index 749215ff5..b329bff28 100644 --- a/util/plaqueModel.py +++ b/util/plaqueModel.py @@ -1,9 +1,9 @@ -# Builds assets/Plaque.obj (+ .mtl) and one STL per colour: a single printable plaque with the Singe +# Builds media/Plaque.obj (+ .mtl) and one STL per colour: a single printable plaque with the Singe # logotype raised on a backing plate and the dragon lying above the word as a bas-relief (its top # half, every part filled down to the plate). It prints face up with no supports: the plate is the # XY plane and Z is up. Five bodies: dark, champagne and glass tiles, the frame (dragon cores, walls, # grout and letter walls) and the plate, for a four-colour printer with the plate sharing a filament. -# Usage: python3 util/plaqueModel.py pieces.json assets/SingeText.jpeg assets/Plaque.obj [--width 160] [--gap 10] [--stl assets] [--check] +# Usage: python3 util/plaqueModel.py pieces.json media/SingeText.jpeg media/Plaque.obj [--width 160] [--gap 10] [--stl media] [--check] import argparse import json import numpy as np diff --git a/util/reelMusic.py b/util/reelMusic.py new file mode 100644 index 000000000..ce1ba8457 --- /dev/null +++ b/util/reelMusic.py @@ -0,0 +1,987 @@ +# Writes the sizzle reel's music: an original FastTracker II module (.xm) in the idiom of the 90s +# PC demoscene, generated rather than sourced, so it is ours outright and carries no licence of +# anyone else's. The engine plays XM natively through libxmp (scene 99 exercises the format), so +# the reel can run as a real Singe script with this as its soundtrack instead of being rendered. +# +# python3 util/reelMusic.py [] (default: .builddir/reelMusic.xm) +# +# Nothing here is sampled from a record or lifted from another module: every instrument is +# synthesised a sample at a time, and the patterns are written a row at a time. It is hard rock -- +# distorted guitars doubled hard left and right, a kit hit like a kit, and a riff in E rather than +# an arpeggio in A. +# +# What keeps it from sounding like a chiptune is where the work goes. The drums are 16 bit at +# 22050 Hz, each built as a transient, a body, and a tail and then soft clipped together, which is +# what punch is. The guitars are a power chord distorted as a chord, root, fifth and octave through +# one saturator, so the intermodulation between them is real rather than three clean notes played at +# once; then a cabinet: high pass, low pass, and a presence bump. +# +# The trap in baking a filter into a sample: the filter travels with the pitch. A cabinet designed +# at the rate the data was generated lands an octave and a half lower when the sample is played at +# low E, and the chord comes out muffled. So every filter here is designed against the rate the +# data will really play at -- 6922 Hz for a riff on the bottom two strings, 3461 Hz for the bass, +# 22050 Hz for a drum struck at C-4 -- which is what the RATE arguments below are for. +# +# The format is FastTracker II 1.04: a 336 byte header with its order table, then the patterns as +# packed rows, then the instruments each carrying their samples with the data delta encoded. A +# sample rendered at 22050 Hz needs relative note +17 and finetune -28 for C-4 to play it back at +# the rate it was made at, which is what tuning() works out. +import math +import os +import struct +import sys +import wave + + +BASE = 8363.0 # What C-4 plays a sample at with the linear frequency table. +RATE = 22050 # What the drums are rendered at: real rate, real air. +TONE_RATE = 21977 # The tuned instruments: 84 samples a cycle at concert C-4, exactly. +TONE_CYCLE = 84 +GUITAR_RATE = 6922 # What the data plays at on low E, which is where the riff lives. +BASS_RATE = 3461 # An octave below that, where the bass guitar lives. +LEAD_RATE = 13844 # An octave above, where a solo sits. +ROWS = 128 # Rows to a pattern: four bars at eight rows to the beat. +CHANNELS = 14 +SPEED = 3 # Ticks a row. +BPM = 150 # Arena tempo: fast enough to lift, slow enough to ring. +HEADER_SIZE = 276 +INSTRUMENT_HEADER = 263 +SAMPLE_HEADER = 40 +PEAK = 30000 # Where a sample's loudest point sits in 16 bits, leaving headroom. + +# The channels, laid out like a band: a kit, a bass, two rhythm guitars and a lead. +KICK, SNARE, HAT, CRASH, TOM, BASS, LEFT, RIGHT, CHUG, LEAD, HARM, OCT, STAB, FX = range(CHANNELS) + +# Where each sits. The two rhythm guitars go hard left and hard right, which is the sound of every +# rock record ever made: the same part played twice and thrown to the walls. +PANNING = { KICK: 0x80, SNARE: 0x80, HAT: 0x60, CRASH: 0xA8, TOM: 0x70, BASS: 0x80, + LEFT: 0x18, RIGHT: 0xE8, CHUG: 0x78, LEAD: 0x90, HARM: 0x64, OCT: 0x88, + STAB: 0xC0, FX: 0x40 } + +NOTES = { "C": 0, "C#": 1, "D": 2, "D#": 3, "E": 4, "F": 5, "F#": 6, "G": 7, "G#": 8, "A": 9, "A#": 10, "B": 11 } +NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + + +def note(name): + if name is None: + return 0 + return 1 + int(name[-1]) * 12 + NOTES[name[:-1]] + + +def transpose(name, semitones): + value = note(name) - 1 + semitones + + return "%s%d" % (NAMES[value % 12], value // 12) + + +# The relative note and finetune that make C-4 play a sample back at the rate it was rendered at. +def tuning(rate): + exact = 12.0 * math.log(rate / BASE, 2) + rel = int(round(exact)) + + return rel, int(round((exact - rel) * 128)) + + +# ===== Real samples =========================================================================== +# +# If util/reelSamples.py has prepared the FreePats sound banks, the module is built from real +# recordings: a Fender through an amplifier rack (CC0), a Yamaha bass (CC0), and a real kit +# (CC BY 4.0, so a reel using it owes Bjorn Muldjord a credit). With no samples there, everything +# falls back to the synthesised set below and the module owes nobody anything. + +SAMPLES = os.environ.get("REEL_SAMPLES", "") +REALRATE = 32000 + + +def loadWave(path): + with wave.open(path, "rb") as handle: + frames = handle.readframes(handle.getnframes()) + + return list(struct.unpack("<%dh" % (len(frames) // 2), frames)), handle.getframerate() + + +# What relative note and finetune make the tracker play a sample at its own rate when the written +# note is the one it was recorded at. MIDI 60 is C-4, which the format calls note 49. +def sampleTuning(rate, midi): + exact = 12.0 * math.log(rate / BASE, 2) - (midi - 60) + rel = int(round(exact)) + + return rel, int(round((exact - rel) * 128)) + + +# ===== Synthesis ============================================================================== + +class Noise: + # Deterministic, so two runs of this program write the same file. + def __init__(self, seed=12345): + self.state = seed + + def next(self): + self.state = (self.state * 1103515245 + 12345) & 0x7FFFFFFF + + return ((self.state >> 11) % 4096 - 2048) / 2048.0 + + +def saturate(value, drive=1.0): + # The soft clip that turns level into loudness: it rounds the top off a transient instead of + # squaring it, and the harmonics it adds are what make a kick audible on a small speaker. + return math.tanh(value * drive) / math.tanh(drive) if drive > 0 else value + + +def normalise(samples, peak=PEAK): + loudest = max(abs(v) for v in samples) or 1.0 + + return [int(max(-32768, min(32767, round(v * peak / loudest)))) for v in samples] + + +def lowpass(samples, cut, rate=RATE, passes=1): + # One pole, run as many times as the slope wants. Cheap, and the right shape for drums. + alpha = 1.0 - math.exp(-2.0 * math.pi * cut / rate) + for _ in range(passes): + out = [] + last = 0.0 + for value in samples: + last += alpha * (value - last) + out.append(last) + samples = out + + return samples + + +def highpass(samples, cut, rate=RATE): + low = lowpass(samples, cut, rate) + + return [value - low[i] for i, value in enumerate(samples)] + + +def bandpass(samples, low, high, rate=RATE): + return lowpass(highpass(samples, low, rate), high, rate, 2) + + +def envelope(length, attack, decay, hold=0.0): + # Attack in samples, then a hold, then an exponential decay: the shape every drum has. + out = [] + for i in range(length): + if i < attack: + out.append(i / float(max(1, attack))) + elif i < attack + hold: + out.append(1.0) + else: + out.append(math.exp(-(i - attack - hold) / float(max(1, decay)))) + + return out + + +# The kick: a sine whose pitch falls off a cliff, a sub that hangs on under it, and a click on the +# front so it reads on a speaker that cannot make 50 Hz. Soft clipped together, which is the punch. +def kick(length=None): + length = length or int(0.42 * RATE) + noise = Noise(7) + body = [] + phase = 0.0 + for i in range(length): + t = i / float(RATE) + pitch = 47.0 + 190.0 * math.exp(-t * 75.0) # 237 Hz down to 47 in about 14 ms. + phase += 2.0 * math.pi * pitch / RATE + body.append(math.sin(phase) * math.exp(-t * 13.0)) + sub = [] + phase = 0.0 + for i in range(length): + t = i / float(RATE) + phase += 2.0 * math.pi * 46.0 / RATE + sub.append(math.sin(phase) * math.exp(-t * 5.5)) + click = lowpass([noise.next() for _ in range(length)], 4200) + shape = envelope(length, 2, int(0.0012 * RATE)) + out = [] + for i in range(length): + mix = body[i] * 1.0 + sub[i] * 0.9 + click[i] * shape[i] * 1.2 + out.append(saturate(mix, 3.0)) + + return normalise(out) + + +# The snare: a shell that rings at two pitches, the wires as bandpassed noise, a bright transient, +# and a short tail so it sounds like a drum in a room rather than a burst of static. +def snare(length=None, bright=1.0): + length = length or int(0.34 * RATE) + noise = Noise(31) + hiss = bandpass([noise.next() for _ in range(length)], 400, 7000 * bright) + tail = bandpass([noise.next() for _ in range(length)], 900, 3000) + out = [] + for i in range(length): + t = i / float(RATE) + shell = (math.sin(2.0 * math.pi * 182.0 * t) + 0.7 * math.sin(2.0 * math.pi * 276.0 * t)) * math.exp(-t * 30.0) + wires = hiss[i] * math.exp(-t * 19.0) + room = tail[i] * math.exp(-t * 7.0) * 0.35 + crack = hiss[i] * math.exp(-t * 900.0) * 1.6 + out.append(saturate(shell * 0.55 + wires * 1.1 + room + crack, 1.6)) + + return normalise(out) + + +# The clap: four bursts a few milliseconds apart and then the tail, which is the whole trick. +def clap(length=None): + length = length or int(0.30 * RATE) + noise = Noise(57) + raw = bandpass([noise.next() for _ in range(length)], 700, 5200) + out = [0.0] * length + for offset in (0, int(0.009 * RATE), int(0.018 * RATE), int(0.026 * RATE)): + gain = 1.0 - offset / float(length) * 1.5 + for i in range(offset, min(length, offset + int(0.02 * RATE))): + out[i] += raw[i] * math.exp(-(i - offset) / (0.004 * RATE)) * gain + for i in range(length): + t = i / float(RATE) + out[i] = saturate(out[i] + raw[i] * math.exp(-t * 11.0) * 0.5, 1.4) + + return normalise(out) + + +def hat(length=None, decay=45.0, cut=9000.0, seed=97): + length = length or int(0.07 * RATE) + noise = Noise(seed) + raw = highpass([noise.next() for _ in range(length)], cut) + out = [] + for i in range(length): + t = i / float(RATE) + out.append(saturate(raw[i] * math.exp(-t * decay), 1.2)) + + return normalise(out, int(PEAK * 0.8)) + + +def crash(length=None): + length = length or int(1.6 * RATE) + noise = Noise(181) + raw = highpass([noise.next() for _ in range(length)], 3500) + out = [] + for i in range(length): + t = i / float(RATE) + # Two decays: the strike, then the wash. + out.append(raw[i] * (math.exp(-t * 14.0) * 0.8 + math.exp(-t * 2.0) * 0.5)) + + return normalise(out, int(PEAK * 0.85)) + + +def tom(pitch=150.0, length=None): + length = length or int(0.22 * RATE) + noise = Noise(211) + raw = bandpass([noise.next() for _ in range(length)], 200, 2500) + out = [] + phase = 0.0 + for i in range(length): + t = i / float(RATE) + f = pitch * (1.0 + 0.5 * math.exp(-t * 40.0)) + phase += 2.0 * math.pi * f / RATE + out.append(saturate(math.sin(phase) * math.exp(-t * 13.0) + raw[i] * math.exp(-t * 60.0) * 0.5, 1.5)) + + return normalise(out) + + +# ----- The guitars ---------------------------------------------------------------------------- +# +# A power chord is not three notes distorted, it is three notes distorted together: the saturator +# multiplies them against each other and the sum and difference tones it makes are most of what a +# heavy guitar is. So the root, the fifth and the octave are summed first and clipped once. The +# loop is sixteen cycles of the root, which is twenty-four of the fifth and thirty-two of the +# octave, so every component closes and the seam is silent. + +def cabinet(samples, rate, low=95.0, high=4300.0, presence=0.45): + # A speaker in a box: nothing under the low corner, little over the high one, and a lift where + # a guitar speaker lifts. Designed against the rate the sample will play at, not be made at. + body = lowpass(highpass(samples, low, rate), high, rate, 2) + shine = bandpass(samples, 1500.0, 3200.0, rate) + + return [value + shine[i] * presence for i, value in enumerate(body)] + + +def strings(ratios, cycles, drive, rate, weights=None, peak=PEAK): + length = cycles * TONE_CYCLE + raw = [] + for i in range(length): + phase = i / float(TONE_CYCLE) + where = i / float(length) + value = 0.0 + for n, ratio in enumerate(ratios): + gain = (weights or [1.0] * len(ratios))[n] + # A string is a saw with the top rolled off and a little life in the phase. + for harmonic in range(1, 13): + drift = 0.008 * math.sin(2.0 * math.pi * (where + n * 0.3 + harmonic * 0.05)) + value += gain * math.sin(2.0 * math.pi * harmonic * ratio * (phase + drift)) / (harmonic ** 1.1) + raw.append(value) + hot = [saturate(v, drive) for v in raw] + + return normalise(cabinet(hot, rate), peak), length + + +# The riff guitar: root, fifth, octave, driven hard. +def powerChord(): + return strings([1.0, 1.5, 2.0], 16, 9.0, GUITAR_RATE, [1.0, 0.85, 0.6]) + + +# The same chord with the gain backed off, for the parts that want to be heard through rather than +# over: the intro and the verses. +def cleanerChord(): + return strings([1.0, 1.5, 2.0], 16, 4.0, GUITAR_RATE, [1.0, 0.8, 0.55]) + + +# One note for the lead, distorted enough to sing and sustain. +def leadGuitar(): + return strings([1.0, 2.0], 32, 7.0, LEAD_RATE, [1.0, 0.25]) + + +# The bass guitar: a pick attack on the front, the string under it, and just enough grit. +def bassGuitar(): + return strings([1.0, 2.0, 3.0], 16, 2.6, BASS_RATE, [1.0, 0.4, 0.15]) + + +# ----- The kit -------------------------------------------------------------------------------- +# +# Hit like a kit rather than programmed like a drum machine: the kick is a thud with a beater on it +# instead of a sub sine, the snare has a shell and a room, and the cymbals are struck. + +def rockKick(length=None): + length = length or int(0.30 * RATE) + noise = Noise(7) + beater = lowpass([noise.next() for _ in range(length)], 5200) + out = [] + phase = 0.0 + for i in range(length): + t = i / float(RATE) + pitch = 58.0 + 120.0 * math.exp(-t * 90.0) # A shorter, higher drop than a dance kick. + phase += 2.0 * math.pi * pitch / RATE + body = math.sin(phase) * math.exp(-t * 17.0) + skin = math.sin(2.0 * math.pi * 210.0 * t) * math.exp(-t * 60.0) * 0.35 + click = beater[i] * math.exp(-t * 320.0) * 1.3 + out.append(saturate(body + skin + click, 2.4)) + + return normalise(out) + + +def rockSnare(length=None): + length = length or int(0.40 * RATE) + noise = Noise(31) + wires = bandpass([noise.next() for _ in range(length)], 500, 8500) + room = bandpass([noise.next() for _ in range(length)], 700, 4000) + out = [] + for i in range(length): + t = i / float(RATE) + shell = (math.sin(2.0 * math.pi * 198.0 * t) + 0.65 * math.sin(2.0 * math.pi * 301.0 * t)) * math.exp(-t * 26.0) + out.append(saturate(shell * 0.7 + wires[i] * math.exp(-t * 16.0) * 1.15 + + room[i] * math.exp(-t * 5.5) * 0.45 + + wires[i] * math.exp(-t * 700.0) * 1.7, 1.7)) + + return normalise(out) + + +def ride(length=None): + length = length or int(0.9 * RATE) + noise = Noise(151) + wash = highpass([noise.next() for _ in range(length)], 4000) + out = [] + for i in range(length): + t = i / float(RATE) + ping = math.sin(2.0 * math.pi * 1180.0 * t) * math.exp(-t * 26.0) * 0.5 + out.append(saturate(ping + wash[i] * (math.exp(-t * 18.0) * 0.7 + math.exp(-t * 3.0) * 0.35), 1.3)) + + return normalise(out, int(PEAK * 0.85)) + + + +class Instrument: + def __init__(self, name, samples, loop, rate, volume=64, envelope=None, sustain=None, fadeout=0, midi=None): + self.name = name + self.samples = samples + self.loop = loop + self.volume = volume + self.envelope = envelope or [] + self.sustain = sustain + self.fadeout = fadeout + self.relative, self.finetune = tuning(rate) if midi is None else sampleTuning(rate, midi) + + +# The guitars a real set carries, as (name, file, MIDI note it was recorded at). A riff note is +# played by whichever of them was recorded nearest it, so nothing is stretched further than a few +# semitones and the amplifier's own character survives. +GUITARS = [("gtrC2", 36), ("gtrE2", 40), ("gtrA2", 45), ("gtrD3", 50), ("gtrE3", 52), ("gtrG3", 55), + ("gtrB3", 59), ("gtrCs4", 61), ("gtrE4", 64), ("gtrG4", 67), ("gtrB4", 71), ("gtrC5", 72), + ("gtrD5", 74)] +# The softer takes of the same guitar, by the note they were recorded at. +SOFT = [("sftC2", 36), ("sftE2", 40), ("sftA2", 45), ("sftG3", 55), ("sftB3", 59), ("sftD5", 74)] + +# Filled by instruments(): which instrument name to use for a written note, and whether the set +# being used is the real one. +CHOICE = {} +REAL = False + + +# Every take of each drum and each guitar note, in the order to rotate through them. +TAKES = { + "kick": ("kick", "kickB", "kickC"), + "kickMid": ("kickMid", "kickMidB"), + "snare": ("snare", "snareB", "snareC", "snareD"), + "hat": ("hat", "hatB", "hatC", "hatD"), + "crash": ("crash", "crashB"), + "tom": ("tom", "tomB"), + "gtrE2": ("gtrE2", "gtrE2b", "gtrE2c", "gtrE2d"), + "gtrA2": ("gtrA2", "gtrA2b", "gtrA2c", "gtrA2d"), + "gtrD3": ("gtrD3", "gtrD3b", "gtrD3c"), + "gtrE3": ("gtrE3", "gtrE3b"), + "gtrG3": ("gtrG3", "gtrG3b", "gtrG3c", "gtrG3d"), + "gtrCs4": ("gtrCs4", "gtrCs4b"), + "gtrE4": ("gtrE4", "gtrE4b", "gtrE4c"), + "gtrB4": ("gtrB4", "gtrB4b"), + "gtrC5": ("gtrC5", "gtrC5b"), + "gtrD5": ("gtrD5", "gtrD5b"), +} +TURN = {} + + +# The next take of something. Every note written goes through this, so a bar of eighths on the hat +# uses four recordings of the hat rather than one of them eight times. Playing the identical +# waveform on every hit is what makes a sampled kit sound like a drum machine. +def take(n, name): + options = [item for item in TAKES.get(name, (name,)) if item in n] + + if not options: + return name + TURN[name] = (TURN.get(name, -1) + 1) % len(options) + + return options[TURN[name]] + + +def realInstruments(): + out = [] + # The kit, with its velocity layers: a hard hit and a softer one for the kick, the snare and the + # hat, plus a ghost snare, an open hat, a ride, a china and two toms. + volumes = { "kick": 64, "kickMid": 58, "snare": 64, "snareMid": 56, "ghost": 40, "hat": 50, + "hatMid": 42, "open": 50, "ride": 46, "china": 52, "crash": 58, "tom": 62, + "tomLow": 62 } + for label in ("kick", "kickB", "kickC", "kickMid", "kickMidB", "snare", "snareB", "snareC", + "snareD", "snareMid", "ghost", "hat", "hatB", "hatC", "hatD", "hatMid", "open", + "ride", "china", "crash", "crashB", "tom", "tomB", "tomLow"): + path = os.path.join(SAMPLES, label + ".wav") + if not os.path.isfile(path): + continue + data, rate = loadWave(path) + out.append(Instrument(label, data, False, rate, volumes.get(label.rstrip("BCD"), 56))) + data, rate = loadWave(os.path.join(SAMPLES, "bass.wav")) + out.append(Instrument("bass", data, False, rate, 44, None, None, 900, 28)) + for name, midi in GUITARS: + for label in TAKES.get(name, (name,)): + path = os.path.join(SAMPLES, label + ".wav") + if os.path.isfile(path): + data, rate = loadWave(path) + out.append(Instrument(label, data, False, rate, 46, None, None, 4200, midi)) + # The softer takes of the same strings, for anything that is not hit flat out. + for name, midi in SOFT: + data, rate = loadWave(os.path.join(SAMPLES, name + ".wav")) + out.append(Instrument(name, data, False, rate, 42, None, None, 4200, midi)) + # Three muted guitars rather than one. A single low E sample stretched over every pitch goes + # nasal as it climbs, which is what made the guitars sound like bagpipes; a mute taken from the + # string that would really be played keeps the register honest. + for label, source, midi in (("mute", "gtrE2", 40), ("muteMid", "gtrG3", 55), ("muteHigh", "gtrB3", 59)): + data, rate = loadWave(os.path.join(SAMPLES, source + ".wav")) + out.append(Instrument(label, data, False, rate, 46, [(0, 64), (1, 40), (3, 14), (5, 0)], None, 0, midi)) + + return out + + +def synthInstruments(): + crunch, crunchLoop = powerChord() + clean, cleanLoop = cleanerChord() + solo, soloLoop = leadGuitar() + bass, bassLoop = bassGuitar() + + return [ + Instrument("kick", rockKick(), False, RATE, 64), + Instrument("snare", rockSnare(), False, RATE, 64), + Instrument("hat", hat(int(0.06 * RATE), 55.0, 9500), False, RATE, 44), + Instrument("crash", crash(), False, RATE, 52), + Instrument("tom", tom(170.0), False, RATE, 56), + Instrument("ride", ride(), False, RATE, 46), + Instrument("bass", bass, bassLoop, TONE_RATE, 64, + [(0, 64), (2, 62), (16, 54), (48, 50)], 3, 600), + Instrument("crunch", crunch, crunchLoop, TONE_RATE, 62, + [(0, 56), (1, 64), (10, 58), (40, 54)], 3, 700), + Instrument("clean", clean, cleanLoop, TONE_RATE, 56, + [(0, 50), (1, 60), (12, 50), (40, 44)], 3, 800), + Instrument("mute", crunch, crunchLoop, TONE_RATE, 54, + [(0, 64), (2, 40), (6, 16), (12, 0)], None, 0), + Instrument("lead", solo, soloLoop, TONE_RATE, 60, + [(0, 20), (2, 64), (14, 58), (60, 56)], 3, 380), + ] + + +def instruments(): + global REAL + + REAL = bool(SAMPLES) and os.path.isfile(os.path.join(SAMPLES, "gtrE2.wav")) + out = realInstruments() if REAL else synthInstruments() + # Which guitar plays a written note. With the real set, the nearest recording; with the + # synthesised one, the same two waveforms whatever the note. + CHOICE.clear() + for octave in range(0, 8): + for step, letter in enumerate(NAMES): + name = "%s%d" % (letter, octave) + midi = note(name) + 11 + if REAL: + CHOICE[name] = min(GUITARS, key=lambda pair: abs(pair[1] - midi))[0] + else: + CHOICE[name] = "crunch" + + return out + + +# The guitar to use for a note: the nearest recording of one. +def guitarFor(name, kind="crunch"): + if not REAL: + return kind + + return CHOICE.get(name, "gtrE2") + + +def numbering(kit): + return {item.name: n + 1 for n, item in enumerate(kit)} + + +# ===== The patterns =========================================================================== + +class Pattern: + def __init__(self, rows=ROWS): + self.rows = rows + self.cells = [[None] * CHANNELS for _ in range(rows)] + + def put(self, row, channel, name=None, instrument=0, volume=0, effect=0, param=0): + if 0 <= row < self.rows: + pitch = note(name) if isinstance(name, str) else (name or 0) + self.cells[row][channel] = (pitch, instrument, volume, effect, param) + + def pack(self): + out = bytearray() + for row in self.cells: + for cell in row: + if cell is None: + out.append(0x80) + continue + pitch, instrument, volume, effect, param = cell + mask = 0x80 + body = bytearray() + if pitch: + mask |= 0x01 + body.append(pitch) + if instrument: + mask |= 0x02 + body.append(instrument) + if volume: + mask |= 0x04 + body.append(volume) + if effect: + mask |= 0x08 + body.append(effect) + if param: + mask |= 0x10 + body.append(param) + out.append(mask) + out += body + + return bytes(out) + + +# ===== The music ============================================================================== +# +# Arena rock in D, which is what the owner asked for after three passes of something darker: a major +# key, chords left to ring instead of chugged, and a hook that sings over the top. The progression +# is the one every anthem is built on -- D, A, B minor, G, the first, fifth, sixth and fourth of the +# key -- and the chorus starts on the fourth, because starting a chorus above where the verse sat is +# most of what "lift" means. +# +# Power chords throughout, so the thirds are left out and the melody decides the mood. The hook +# rises to a held D and stays there; the last chorus puts it an octave up, which is the oldest trick +# there is for making a final chorus feel bigger without playing anything louder. + +# The key. A mixolydian: the major third with the flat seventh, which is why a G chord sits next to +# an A one in this style. Every ornament, passing tone and harmony note is snapped to it, because +# moving a note by a tone without asking what key you are in is how wrong notes get in. +KEY = (9, 11, 1, 2, 4, 6, 7) + + +def inKey(name): + value = note(name) - 1 + best = min(range(-6, 7), key=lambda shift: (abs(shift), 0) if (value + shift) % 12 in KEY else (99, 0)) + + return "%s%d" % (NAMES[(value + best) % 12], (value + best) // 12) + + +# What the second voice plays over each chord: the tones a power chord leaves out. +COUNTER = { "A": ("C#5", "E4"), "D": ("F#4", "A4"), "E": ("G#4", "B4"), "G": ("B4", "D5"), + "C": ("E4", "G4"), "B": ("D5", "F#4"), "F": ("A4", "C5") } + + +# The riff. A, with the stops that make this style: a chord, a hole, a chord off the beat. Nothing +# is held for a bar anywhere in this track -- that was the slow part and it is gone. +VERSE = [ + (0, "A2", 5, True), (8, "A2", 3, False), (14, "G2", 5, True), (24, "A2", 3, False), + (32, "A2", 5, True), (40, "D2", 3, False), (46, "A2", 5, True), (56, "E2", 5, True), + (64, "A2", 5, True), (72, "A2", 3, False), (78, "G2", 5, True), (88, "A2", 3, False), + (96, "D2", 5, True), (104, "D2", 3, False), (110, "E2", 5, True), (120, "A2", 7, True), +] + +# The chorus: the fourth, the first, the fifth, the first, a chord to a half bar, bright and moving. +CHORUS = [ + (0, "D2", 6, True), (16, "A2", 6, True), (32, "E2", 6, True), (48, "A2", 6, True), + (64, "D2", 6, True), (80, "A2", 6, True), (96, "G2", 6, True), (112, "A2", 6, True), +] + +# The hook, rhythmic rather than sung: two short notes and an answer, twice, then the same idea +# higher. A major pentatonic, A B C# E F#. +HOOK = [ + (0, "A4", 4), (6, "C#5", 4), (12, "E5", 8), (24, "C#5", 6), + (32, "A4", 4), (38, "B4", 4), (44, "C#5", 10), (58, "A4", 5), + (64, "E5", 4), (70, "F#5", 4), (76, "E5", 8), (88, "C#5", 6), + (96, "B4", 4), (102, "C#5", 4), (108, "A4", 14), +] + +# The solo: sixteenth runs up and down the pentatonic, which is what this guitarist actually does, +# rather than long held notes. +SOLO = [ + (0, "A4", 2), (2, "B4", 2), (4, "C#5", 2), (6, "E5", 2), (8, "F#5", 6), (16, "E5", 2), + (18, "C#5", 2), (20, "B4", 2), (22, "A4", 6), + (32, "C#5", 2), (34, "E5", 2), (36, "F#5", 2), (38, "A5", 8), (48, "F#5", 2), (50, "E5", 2), + (52, "C#5", 2), (54, "B4", 8), + (64, "E5", 2), (66, "F#5", 2), (68, "A5", 2), (70, "F#5", 2), (72, "E5", 6), (80, "C#5", 2), + (82, "B4", 2), (84, "A4", 10), + (96, "B4", 2), (98, "C#5", 2), (100, "E5", 2), (102, "F#5", 2), (104, "A5", 12), + (120, "E5", 7), +] +ANSWER = [ + (24, "A4", 4), (30, "C#5", 4), (36, "E5", 6), (88, "F#5", 4), (94, "E5", 4), (100, "C#5", 10), +] +LIFT = SOLO + + +def panning(pattern): + for channel, where in PANNING.items(): + for row in range(0, 6): + cell = pattern.cells[row][channel] + + if cell is None: + pattern.put(row, channel, None, 0, 0, 0x08, where) + break + if cell[3] == 0: + pattern.cells[row][channel] = (cell[0], cell[1], cell[2], 0x08, where) + break + + +# ----- Playing it like a player ---------------------------------------------------------------- +# +# The first attempt at this was sparse and repetitive, which sounded programmed. The second fixed +# that by randomising everything -- which offbeat the bass took, which hat dropped out, where a fill +# landed -- and the owner's word for the result was "haphazard". He is right: random is not human. +# A player repeats a figure on purpose and varies it where the phrase turns. +# +# So every rhythm below is written out once, as rows inside a bar, and played the same way every bar. +# The only thing left to chance is a decibel or two on a hit, because a drummer's accents are +# consistent and their absolute level is not. Variation happens where music puts it: the fourth bar +# of a four bar phrase. + +BAR = 32 # Rows in a bar at eight rows to the beat. + +# The groove. Kick on one, on the "a" of one, and on three; snare on two and four with a ghost +# before the last of them; eighths on the hat, accented on the beat; the hat left open on the +# "and" of two. This is one bar of rock drumming, written once. +KICK_ROWS = ((0, 0x50), (6, 0x3E), (16, 0x4C)) +SNARE_ROWS = ((8, 0x50), (24, 0x50)) +GHOST_ROWS = ((20, 0x24),) +HAT_ROWS = ((0, 0x2E), (4, 0x1E), (8, 0x2A), (12, 0x1E), (16, 0x2E), (20, 0x1E), (24, 0x2A), (28, 0x20)) +OPEN_ROW = 12 + +# The fill that ends a four bar phrase, in the last bar: toms down the kit, snare on the last two. +FILL = ((16, "tom", 0x3A), (20, "tom", 0x3E), (24, "tomLow", 0x42), (26, "tomLow", 0x42), + (28, "snare", 0x46), (30, "snare", 0x4A)) + +# The right hand. Sixteenths, accented on the beat, with two deliberate holes: the sixteenth before +# beat four and the last one of the bar. Written once and played the same every bar, which is what +# makes it a part rather than a texture. +DRIVE_ROWS = ((0, 0x44), (2, 0x30), (4, 0x38), (6, 0x30), (8, 0x40), (10, 0x30), (12, 0x38), + (16, 0x44), (18, 0x30), (20, 0x38), (22, 0x30), (24, 0x40), (26, 0x30), (28, 0x38)) + +# The bass. Root on one, root on the "a" of one, root on three, octave on the "and" of two and the +# "and" of four: a figure, not a scatter of sixteenths. +BASS_ROWS = ((0, 0, 0x42), (6, 0, 0x34), (12, 12, 0x30), (16, 0, 0x3E), (22, 0, 0x32), (28, 12, 0x32)) + +# The stabs: the "and" of four in the second bar of a phrase, and twice in the fourth. +STAB_ROWS = { 1: ((28, 0x42),), 3: ((20, 0x3E), (28, 0x44)) } + + +class Rng: + # Only used for a decibel or two on a hit, so two runs still write the same file. + def __init__(self, seed): + self.state = seed * 2654435761 & 0xFFFFFFFF + + def next(self, under): + self.state = (self.state * 1103515245 + 12345) & 0x7FFFFFFF + + return (self.state >> 13) % under + + +def human(rng, level, spread=2): + return max(0x11, min(0x50, level - rng.next(spread * 2 + 1) + spread)) + + +def muteFor(name): + if not REAL: + return "mute" + midi = note(name) + 11 + + return "mute" if midi < 48 else ("muteMid" if midi < 58 else "muteHigh") + + +def guitarSoft(name): + if not REAL: + return guitarFor(name) + midi = note(name) + 11 + + return min(SOFT, key=lambda pair: abs(pair[1] - midi))[0] + + +# The kit: the written groove every bar, the written fill in the last bar of each phrase. +def kit(pattern, n, rng, ride=False, halfTime=False, fills=True): + plate = "ride" if (ride and "ride" in n) else "hat" + bars = pattern.rows // BAR + for bar in range(bars): + at = bar * BAR + last = fills and (bar % 4 == 3) + for row, level in KICK_ROWS: + pattern.put(at + row, KICK, "C4", n[take(n, "kick")], human(rng, level)) + for row, level in SNARE_ROWS: + if last and row == 24: + continue # The fill has that beat. + pattern.put(at + row, SNARE, "C4", n[take(n, "snare")], human(rng, level)) + for row, level in GHOST_ROWS: + if not last and "ghost" in n: + pattern.put(at + row, SNARE, "C4", n["ghost"], human(rng, level)) + for row, level in HAT_ROWS: + if last and row >= 16: + continue + what = "open" if (row == OPEN_ROW and bar % 2 == 1 and "open" in n) else plate + pattern.put(at + row, HAT, "C4", n[take(n, what)], human(rng, level)) + if last: + for row, what, level in FILL: + pattern.put(at + row, TOM, transpose("C4", 4 - row // 8), n[take(n, what)], human(rng, level)) + + +# The chords, with the written bass figure under them. +def chords(pattern, n, rng, figure, level=0x40, bass=True, octave=False, soft=False): + for row, chord, hold, accent in figure: + pick = guitarSoft if (soft or not accent) else guitarFor + pattern.put(row, LEFT, chord, n[take(n, pick(chord))], human(rng, level)) + pattern.put(row, RIGHT, chord, n[guitarSoft(chord)], human(rng, level - 1), 0x0E, 0xD1) + if row + hold < pattern.rows: + pattern.put(row + hold, LEFT, 97) + pattern.put(row + hold, RIGHT, 97) + if bass: + # The figure repeats every bar, on whatever chord the bar is on. + for bar in range(pattern.rows // BAR): + at = bar * BAR + chord = chordAt(figure, at) + for row, step, level2 in BASS_ROWS: + pattern.put(at + row, BASS, inKey(transpose(chord, -12 + step)), n["bass"], human(rng, level2)) + + +# Which chord a row falls on. +def chordAt(figure, row): + chord = figure[0][1] + for at, name, hold, accent in figure: + if at <= row: + chord = name + return chord + + +def drive(pattern, n, rng, figure, level=0x00): + for bar in range(pattern.rows // BAR): + at = bar * BAR + chord = chordAt(figure, at) + for row, own in DRIVE_ROWS: + pattern.put(at + row, CHUG, chord, n[take(n, muteFor(chord))], human(rng, own + level)) + + +def stabs(pattern, n, rng, figure, level=0x00, per=0): + for bar in range(pattern.rows // BAR): + at = bar * BAR + chord = chordAt(figure, at) + above = transpose(chord, 12) + for row, own in STAB_ROWS.get(bar % 4, ()): + pattern.put(at + row, STAB, above, n[take(n, guitarFor(above))], human(rng, own + level)) + pattern.put(at + row + 3, STAB, 97) + + +# The second voice: a written line over each chord, the third and the fifth of it, in a fixed +# rhythm. Two notes a bar, always in the same place. +def counter(pattern, n, rng, figure, level=0x30, every=16): + for bar in range(pattern.rows // BAR): + at = bar * BAR + chord = chordAt(figure, at) + tones = COUNTER.get(chord[:-1], ("A4", "D5")) + for i, row in enumerate((4, 20)): + name = tones[i % len(tones)] + pattern.put(at + row, HARM, name, n[guitarSoft(name)], human(rng, level)) + pattern.put(at + row + 10, HARM, 97) + + +def crashes(pattern, n, rng, rows, level=0x4A): + for row in rows: + pattern.put(row, CRASH, "C4", n[take(n, "crash")], human(rng, level)) + + +# The tune. The ornaments are written in rather than diced: a grace note into the first long note of +# each half, and nothing else. +GRACED = (32, 96) + + +def lead(pattern, n, rng, tune, channel=LEAD, level=0x4E, octave=0, ornament=True): + for row, name, hold in tune: + played = transpose(name, octave) + if ornament and row in GRACED: + grace = inKey(transpose(played, -2)) + pattern.put(row - 1, channel, grace, n[guitarSoft(grace)], human(rng, level - 12)) + pattern.put(row, channel, played, n[take(n, guitarFor(played))], human(rng, level)) + if row + hold < pattern.rows: + pattern.put(row + hold, channel, 97) + + +def song(): + kitList = instruments() + n = numbering(kitList) + out = [] + # No slow bits anywhere: the riff opens it, and every entry after that is the riff, the chorus or + # the solo. Nothing fades in, nothing drops to half time, nothing holds a chord for a bar. + plan = [("riff", 1), ("riff", 2), ("chorus", 3), ("riff", 4), ("chorus", 5), ("solo", 6), + ("chorus", 7), ("riff", 8), ("chorus", 9), ("solo", 10), ("chorus", 11)] + for kind, seed in plan: + rng = Rng(seed) + pattern = Pattern() + + if seed == 1: + pattern.put(0, 0, None, 0, 0, 0x0F, SPEED) + if kind == "riff": + chords(pattern, n, rng, VERSE, 0x42) + drive(pattern, n, rng, VERSE) + kit(pattern, n, rng, seed % 3 == 0) + stabs(pattern, n, rng, VERSE) + crashes(pattern, n, rng, (0, 64), 0x44) + if seed > 2: + lead(pattern, n, rng, ANSWER, LEAD, 0x4A) + elif kind == "chorus": + chords(pattern, n, rng, CHORUS, 0x46) + drive(pattern, n, rng, CHORUS, -4) + kit(pattern, n, rng) + counter(pattern, n, rng, CHORUS, 0x30) + stabs(pattern, n, rng, CHORUS) + crashes(pattern, n, rng, (0, 32, 64, 96)) + lead(pattern, n, rng, HOOK, LEAD, 0x4C) + lead(pattern, n, rng, [(r, inKey(transpose(name, -3)), h) for r, name, h in HOOK], + HARM, 0x36, 0, False) + else: + chords(pattern, n, rng, VERSE, 0x3E) + drive(pattern, n, rng, VERSE, -2) + kit(pattern, n, rng, True) + crashes(pattern, n, rng, (0, 64), 0x40) + lead(pattern, n, rng, SOLO, LEAD, 0x4E, 0, False) + panning(pattern) + out.append(pattern) + + return kitList, out, list(range(len(out))), 0 + + +# ===== Writing the file ======================================================================= + +def text(value, length): + raw = value.encode("ascii", "replace")[:length] + + return raw + b"\0" * (length - len(raw)) + + +def delta16(samples): + out = bytearray() + last = 0 + for value in samples: + out += struct.pack(" 1 else os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".builddir", "reelMusic.xm") + write(target) diff --git a/util/reelSamples.py b/util/reelSamples.py new file mode 100644 index 000000000..d53241b45 --- /dev/null +++ b/util/reelSamples.py @@ -0,0 +1,244 @@ +# Prepares the real instrument samples the reel's music is built from. +# +# python3 util/reelSamples.py +# +# The sources are FreePats sound banks, fetched by hand and kept outside the repository because they +# are hundreds of megabytes: +# +# * Electric Guitar FSBS (dist2) a Fender through an amplifier and an effects rack, CC0 1.0 +# https://freepats.zenvoid.org/ElectricGuitar/distorted-electric-guitar.html +# * Picked Bass YR a Yamaha RBX, CC0 1.0 +# https://freepats.zenvoid.org/ElectricGuitar/clean-electric-bass.html +# * MuldjordKit a real drum kit, Creative Commons Attribution 4.0 +# https://freepats.zenvoid.org/Percussion/acoustic-drum-kit.html +# +# The guitar and the bass are public domain and carry no obligation at all. The kit is CC BY, so a +# reel that uses it owes Bjorn Muldjord a credit; everything here is arranged so the kit can be +# swapped for the synthesised one in reelMusic.py if that is unwelcome. +# +# What this does to each sample: down to one channel at 32000 Hz, the silence in front cut off so a +# hit lands the moment it is triggered, a length that suits its part, a short fade so a truncated +# tail cannot click, and a peak normalised into 16 bits with headroom. Nothing else -- the playing +# is theirs. +import math +import os +import struct +import subprocess +import sys +import wave + +import numpy +from scipy.signal import fftconvolve + + +RATE = 32000 +PEAK = 30000 +SILENT = 0.012 # A hit starts where the signal first passes this fraction of its peak. +FADE = 0.02 # Seconds of fade at the end, so a cut tail does not click. + +# What to take, what to call it, the MIDI note it was recorded at (None for a drum), and how many +# seconds of it to keep. +# What to take, what to call it, the MIDI note it was recorded at (None for a drum), and how many +# seconds of it to keep. Where a name ends in a letter -- kickB, gtrA2c -- it is another take of the +# same thing, and the module rotates through them so no two hits are the same waveform. That +# rotation is the difference between a sampled instrument and a machine gun. +WANTED = [ + # The kit: three velocities of the kick, the snare and the hat, and three takes of each of + # those, so a bar of eighths never repeats a sample. + ("25-KdrumL.flac", "kick", None, 0.60), + ("24-KdrumL.flac", "kickB", None, 0.60), + ("23-KdrumL.flac", "kickC", None, 0.60), + ("13-KdrumL.flac", "kickMid", None, 0.55), + ("21-KdrumL.flac", "kickMidB", None, 0.55), + ("56-Snare.flac", "snare", None, 0.70), + ("55-Snare.flac", "snareB", None, 0.70), + ("53-Snare.flac", "snareC", None, 0.70), + ("51-Snare.flac", "snareD", None, 0.70), + ("30-Snare.flac", "snareMid", None, 0.60), + ("5-Snare.flac", "ghost", None, 0.40), + ("29-HihatClosed.flac", "hat", None, 0.22), + ("28-HihatClosed.flac", "hatB", None, 0.22), + ("27-HihatClosed.flac", "hatC", None, 0.22), + ("26-HihatClosed.flac", "hatD", None, 0.22), + ("14-HihatClosed.flac", "hatMid", None, 0.20), + ("29-HihatOpen.flac", "open", None, 0.60), + ("5-RideL.flac", "ride", None, 1.10), + ("1-China.flac", "china", None, 1.80), + ("9-CrashL.flac", "crash", None, 2.40), + ("8-CrashL.flac", "crashB", None, 2.40), + ("13-Tom2.flac", "tom", None, 0.55), + ("10-Tom2.flac", "tomB", None, 0.55), + ("13-Tom4.flac", "tomLow", None, 0.70), + # The guitar, four takes of everything the riff and the solo actually use. + ("E2_s1_01.flac", "gtrE2", 40, 1.70), + ("E2_s1_02.flac", "gtrE2b", 40, 1.70), + ("E2_s1_03.flac", "gtrE2c", 40, 1.70), + ("E2_s1_04.flac", "gtrE2d", 40, 1.70), + ("A2_s2_01.flac", "gtrA2", 45, 1.70), + ("A2_s2_02.flac", "gtrA2b", 45, 1.70), + ("A2_s2_03.flac", "gtrA2c", 45, 1.70), + ("A2_s2_04.flac", "gtrA2d", 45, 1.70), + ("C2_s1_01.flac", "gtrC2", 36, 1.70), + ("D3_s3_01.flac", "gtrD3", 50, 1.50), + ("D3_s3_02.flac", "gtrD3b", 50, 1.50), + ("D3_s3_03.flac", "gtrD3c", 50, 1.50), + ("E3_s3_01.flac", "gtrE3", 52, 1.50), + ("E3_s3_02.flac", "gtrE3b", 52, 1.50), + ("G3_s4_01.flac", "gtrG3", 55, 1.60), + ("G3_s4_02.flac", "gtrG3b", 55, 1.60), + ("G3_s4_03.flac", "gtrG3c", 55, 1.60), + ("G3_s4_04.flac", "gtrG3d", 55, 1.60), + ("B3_s5_01.flac", "gtrB3", 59, 2.00), + ("C#4_s5_01.flac", "gtrCs4", 61, 2.00), + ("C#4_s5_02.flac", "gtrCs4b", 61, 2.00), + ("E4_s6_01.flac", "gtrE4", 64, 2.20), + ("E4_s6_02.flac", "gtrE4b", 64, 2.20), + ("E4_s6_03.flac", "gtrE4c", 64, 2.20), + ("G4_s6_01.flac", "gtrG4", 67, 2.20), + ("B4_s6_01.flac", "gtrB4", 71, 2.40), + ("B4_s6_02.flac", "gtrB4b", 71, 2.40), + ("C5_s6_01.flac", "gtrC5", 72, 2.40), + ("C5_s6_02.flac", "gtrC5b", 72, 2.40), + ("D5_s6_01.flac", "gtrD5", 74, 2.60), + ("D5_s6_02.flac", "gtrD5b", 74, 2.60), + # The softer takes, for the doubled side and the second voice. + ("E2_s1_soft_01.flac", "sftE2", 40, 1.60), + ("A2_s2_soft_01.flac", "sftA2", 45, 1.60), + ("C2_s1_soft_01.flac", "sftC2", 36, 1.60), + ("G3_s4_soft_01.flac", "sftG3", 55, 1.50), + ("B3_s5_soft_01.flac", "sftB3", 59, 1.80), + ("D5_s6_soft_01.flac", "sftD5", 74, 2.20), + ("E.flac", "bass", 28, 1.30), +] + + +# ===== The room =============================================================================== +# +# These recordings are close miked and dry, and a record is not: a kit is in a room and a guitar +# comes back off the far wall. So each sample gets the space baked into it, which is exactly what the +# sample libraries of the era did -- their drums arrived with the room already on them. +# +# The reverb is built rather than sampled: a handful of discrete early reflections, which is what +# tells the ear how big the room is, and then exponentially decaying noise for the tail. The guitars +# get a slap instead, one repeat a tenth of a second later, which is the oldest guitar effect there +# is and the reason a single tracked part sounds wide. + +# By instrument: how wet the room is, how long it rings, and the slap in milliseconds with its level. +TREATMENT = { + "kick": (0.10, 0.28, None), # Tight: a kick with a long tail is a mess. + "snare": (0.30, 0.45, None), + "ghost": (0.26, 0.40, None), + "hat": (0.16, 0.30, None), + "open": (0.20, 0.40, None), + "ride": (0.20, 0.50, None), + "china": (0.24, 0.60, None), + "crash": (0.22, 0.60, None), + "tom": (0.28, 0.45, None), + "gtr": (0.08, 0.30, (112, 0.34)), # A slap a tenth of a second back, a third of the level. + "sft": (0.08, 0.30, (112, 0.28)), + "bass": (0.00, 0.00, None), # Dry: reverb on a bass is mud. +} + + +def treatmentFor(label): + for prefix in sorted(TREATMENT, key=len, reverse=True): + if label.startswith(prefix): + return TREATMENT[prefix] + + return (0.15, 0.35, None) + + +def impulse(rate, seconds, seed=1234): + # The early reflections first, at the times a room this size would give them, then the tail. + length = int(seconds * rate) + out = numpy.zeros(length) + state = seed + for at, gain in ((0.011, 0.5), (0.017, 0.38), (0.023, 0.30), (0.031, 0.26), (0.043, 0.20)): + index = int(at * rate) + if index < length: + out[index] += gain + for i in range(int(0.008 * rate), length): + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + noise = ((state >> 13) % 2001 - 1000) / 1000.0 + out[i] += noise * math.exp(-5.0 * i / float(length)) * 0.22 + # Nothing below eighty hertz or above eight kilohertz: a room does not reflect either well. + out[0] += 1.0 # The direct sound, so the mix keeps its front. + + return out + + +def space(samples, rate, wet, seconds, slap): + if not samples: + return samples + x = numpy.array(samples, dtype=float) + if slap: + ms, level = slap + for tap, gain in ((ms, level), (ms * 2, level * 0.45)): + shift = int(ms and tap * rate / 1000.0) + if shift < len(x): + padded = numpy.concatenate([numpy.zeros(shift), x]) * gain + x = numpy.concatenate([x, numpy.zeros(shift)]) + padded[:len(x) + shift] + if wet > 0: + wetted = fftconvolve(x, impulse(rate, seconds), mode="full") + wetted = wetted[:len(x) + int(seconds * rate)] + dry = numpy.concatenate([x, numpy.zeros(len(wetted) - len(x))]) + x = dry * (1.0 - wet) + wetted / (numpy.abs(wetted).max() or 1) * numpy.abs(dry).max() * wet + peak = numpy.abs(x).max() or 1.0 + + return [int(max(-32768, min(32767, round(v * PEAK / peak)))) for v in x] + + +def find(root, name): + for base, dirs, files in os.walk(root): + if name in files: + return os.path.join(base, name) + return None + + +def decode(path): + raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-ac", "1", "-ar", str(RATE), + "-f", "s16le", "-"], capture_output=True, check=True).stdout + + return list(struct.unpack("<%dh" % (len(raw) // 2), raw)) + + +def prepare(samples, seconds): + peak = max(abs(v) for v in samples) or 1 + floor = peak * SILENT + start = next((i for i, v in enumerate(samples) if abs(v) > floor), 0) + start = max(0, start - int(0.002 * RATE)) # Two milliseconds of run up. + out = samples[start:start + int(seconds * RATE)] + fade = int(FADE * RATE) + for i in range(max(0, len(out) - fade), len(out)): + out[i] = int(out[i] * (len(out) - i) / float(fade)) + peak = max(abs(v) for v in out) or 1 + + return [int(max(-32768, min(32767, v * PEAK / peak))) for v in out] + + +def main(): + if len(sys.argv) < 3: + sys.exit("usage: reelSamples.py ") + source, out = sys.argv[1], sys.argv[2] + os.makedirs(out, exist_ok=True) + missing = [] + for name, label, midi, seconds in WANTED: + path = find(source, name) + if path is None: + missing.append(name) + continue + wet, rings, slap = treatmentFor(label) + data = space(prepare(decode(path), seconds), RATE, wet, rings, slap) + with wave.open(os.path.join(out, label + ".wav"), "wb") as handle: + handle.setnchannels(1) + handle.setsampwidth(2) + handle.setframerate(RATE) + handle.writeframes(struct.pack("<%dh" % len(data), *data)) + print("%-8s %6d samples %4.2f s %s" % (label, len(data), len(data) / float(RATE), + "midi %d" % midi if midi else "one shot")) + if missing: + print("\nnot found: %s" % ", ".join(missing)) + + +if __name__ == "__main__": + main() diff --git a/util/textModel.py b/util/textModel.py index 569f62e73..790e4cd78 100644 --- a/util/textModel.py +++ b/util/textModel.py @@ -1,4 +1,4 @@ -# Builds assets/SingeText.obj (+ .mtl): the Singe logotype from SingeText.jpeg as solid letters, +# Builds media/SingeText.obj (+ .mtl): the Singe logotype from SingeText.jpeg as solid letters, # extruded with a chamfer on both faces, standing upright in the XY plane with depth along Z, # resting on Y = 0 and centred on X = 0. Convert with util/objToGlb.py for Singe. # @@ -7,8 +7,8 @@ # three colour bodies (letter faces, letter walls, plate); --stl writes one STL per body in # millimetres, --width mm wide, and --check voxelises the result and counts the pieces. # -# Usage: python3 util/textModel.py assets/SingeText.jpeg assets/SingeText.obj [--depth 15] [--chamfer 5] -# python3 util/textModel.py assets/SingeText.jpeg assets/SingeTextPrint.obj --print --stl assets --width 150 --check +# Usage: python3 util/textModel.py media/SingeText.jpeg media/SingeText.obj [--depth 15] [--chamfer 5] +# python3 util/textModel.py media/SingeText.jpeg media/SingeTextPrint.obj --print --stl media --width 150 --check import argparse import os import numpy as np diff --git a/util/traceDragon.py b/util/traceDragon.py index 1907d8061..0cb148335 100644 --- a/util/traceDragon.py +++ b/util/traceDragon.py @@ -1,6 +1,6 @@ -# Reconstructs assets/Dragon.jpeg as assets/Dragon.svg: segments the tiles by material, fits each as a +# Reconstructs media/Dragon.jpeg as media/Dragon.svg: segments the tiles by material, fits each as a # convex polygon, samples a linear gradient per tile, and writes an SVG with a transparent background. -# Usage: python3 util/traceDragon.py assets/Dragon.jpeg assets/Dragon.svg [--debug x.png] [--preview x.png] [--pieces x.json] +# Usage: python3 util/traceDragon.py media/Dragon.jpeg media/Dragon.svg [--debug x.png] [--preview x.png] [--pieces x.json] # --pieces writes the tile polygons and colours for util/dragonObj.py. Needs numpy, scipy and Pillow. import argparse import json diff --git a/util/traceText.py b/util/traceText.py index ccf4f70be..aca62c015 100644 --- a/util/traceText.py +++ b/util/traceText.py @@ -1,6 +1,6 @@ -# Reconstructs assets/SingeText.jpeg as assets/SingeText.svg: thresholds the logotype, traces it +# Reconstructs media/SingeText.jpeg as media/SingeText.svg: thresholds the logotype, traces it # with potrace into Bezier outlines, and writes a tightly cropped SVG with a transparent background. -# Usage: python3 util/traceText.py assets/SingeText.jpeg assets/SingeText.svg [potrace] [check.svg] +# Usage: python3 util/traceText.py media/SingeText.jpeg media/SingeText.svg [potrace] [check.svg] # Needs Pillow and potrace (https://potrace.sourceforge.net). check.svg keeps the full page so it # can be rendered over the original for comparison. import os