diff --git a/CHANGELOG b/CHANGELOG index 217755e50..84190a622 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -587,7 +587,7 @@ API Changes delay (DAC, receiver, Bluetooth, display). Press the service key on the game list; a click plays once a second and the screen flashes. Adjust with the stick until the flash and the click coincide, then - press button 1 to save. The value is kept in audio.cfg in the data + press button 1 to save. The value is kept in machine.cfg in the data root and applied to every game on the machine, from the menu or the command line, on top of any per-game AUDIO_DELAY. Scripts can read and set it with singeGetAudioCalibration() / singeSetAudioCalibration(), @@ -862,6 +862,41 @@ API Changes things that must not pass through each other -- which is what the collide calls deliberately do not do. +- The service tools. Pressing the SERVICE key on the bundled menu now + opens a list of ten screens rather than the audio delay screen alone: + Audio Delay, Input Test, System Information, Light Gun, Sound Test, + Display, Disc Test, Saved Data, MIDI Ports and Frame Statistics. + Button 2 backs out of a tool, the service key leaves. They live in + Singe/Tools.singe, which the engine extracts beside Menu.singe, and + each is a page of Singe/Menu.rml, so a cabinet builder can add one. + MenuClassic keeps the audio delay screen and nothing else. + + Four calls were added for them, and are worth having on their own: + + singeGetSystemInfo() returns everything the trace header knows in one + table -- version, operating system, processor, renderer, 3D device, + video decoders, audio formats, SoundFont, MIDI, window and canvas + sizes, this game's data directory and the data root every game writes + under -- so a bug report can be a photograph. + + vldpGetShift() and vldpSetShift(x, y) move the picture within the + window while the game runs, the running equivalent of --shiftx and + --shifty, beside vldpGetScale and vldpSetScale. + + singeSaveGeometry() keeps the scale, shift and rotation for this + machine, in the same machine.cfg the audio delay uses, and the engine + applies them to every game it starts afterwards unless the command + line names its own. A monitor is squared up once, not once per game. + The file is called machine.cfg rather than audio.cfg, because the + audio delay is no longer all it holds. + + discGetFrameCount() answers how many frames the disc has. A framefile + answers for the whole disc rather than the segment playing. + + SINGE_CONTROLLER_AXES and SINGE_CONTROLLER_BUTTONS join the input + layout globals, so a service screen can walk a pad without knowing the + numbers. + - Subtitles carried inside the video file: discGetSubtitleTracks(), discGetSubtitleLanguage(track) and srtLoadTrack(track), which reads one track out of the disc's own container and loads it exactly as diff --git a/CMakeLists.txt b/CMakeLists.txt index d0ee0e813..ee6aefcf4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -189,6 +189,7 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/Framework.singe ${GENERATED_DIR}/Framework singeEmbed(${CMAKE_SOURCE_DIR}/assets/controls.cfg ${GENERATED_DIR}/controls_cfg.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "") +singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/MenuClassic.singe ${GENERATED_DIR}/MenuClassic_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.rml ${GENERATED_DIR}/Menu_rml.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/menu.rcss ${GENERATED_DIR}/menu_rcss.h "") diff --git a/assets/Menu.rml b/assets/Menu.rml index 7d82bed28..47ede87d3 100644 --- a/assets/Menu.rml +++ b/assets/Menu.rml @@ -28,17 +28,72 @@
- ", class, tool.name) + end + set("toolList", table.concat(rows)) + set("toolHelp", TOOLS[TOOL_SELECTED].help or "") +end + + +function toolsOpen(index) + TOOL_OPEN = TOOLS[index] + if TOOL_OPEN.begin then + TOOL_OPEN.begin() + end + toolsShowPage(TOOL_OPEN.id) +end + + +function toolsClose() + if TOOL_OPEN and TOOL_OPEN.finish then + TOOL_OPEN.finish() + end + TOOL_OPEN = nil + toolsShowList() + toolsShowPage("tools") +end + + +-- Every switch while the tools are up. A tool that wants a switch for itself says so by returning +-- true from its input function; everything else falls through to the rules here, so Button 2 always +-- backs out and SERVICE always leaves. +function toolsInput(what) + if TOOL_OPEN then + if TOOL_OPEN.input and TOOL_OPEN.input(what) then + return + end + if what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then + toolsClose() + end + return + end + if what == SWITCH_UP then + TOOL_SELECTED = (TOOL_SELECTED == 1) and #TOOLS or (TOOL_SELECTED - 1) + toolsShowList() + elseif what == SWITCH_DOWN then + TOOL_SELECTED = (TOOL_SELECTED == #TOOLS) and 1 or (TOOL_SELECTED + 1) + toolsShowList() + elseif what == SWITCH_BUTTON1 or what == SWITCH_START1 or what == SWITCH_START2 then + toolsOpen(TOOL_SELECTED) + elseif what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then + toolsEnd() + end +end + + +function toolsUpdate() + if TOOL_OPEN and TOOL_OPEN.update then + TOOL_OPEN.update() + end +end + + +-- Whether the tools have the screen, which is what the menu asks before doing anything of its own. +function toolsActive() + return TOOL_OPEN ~= nil or not menuElement("tools"):IsClassSet("hidden") +end + + +-- ===== 1. Audio delay ===== + +-- A click every second and a flash scheduled the engine's measured device queue plus the candidate +-- value after it. The click and the disc audio share one mixer device, so when the player sees and +-- hears them as one event the candidate is this machine's delay. +local CAL_PERIOD = 1000 +local CAL_STEP_COARSE = 10 +local CAL_STEP_FINE = 1 +local CAL_LIMIT = 1000 +local calValue, calBeat, calClickDone, calFlashDone + + +local function audioShow() + set("calValue", tostring(calValue)) +end + + +TOOLS[#TOOLS + 1] = { + id = "toolAudio", + name = "Audio Delay", + help = "Line the click up with the flash so sound and picture agree.", + + begin = function() + calValue = singeGetAudioCalibration() + calBeat = singeGetTicks() - CAL_PERIOD + calClickDone = true + calFlashDone = true + set("calLatency", tostring(singeGetAudioLatency())) + audioShow() + end, + + input = function(what) + local delta = 0 + if what == SWITCH_LEFT then + delta = -CAL_STEP_COARSE + elseif what == SWITCH_RIGHT then + delta = CAL_STEP_COARSE + elseif what == SWITCH_UP then + delta = CAL_STEP_FINE + elseif what == SWITCH_DOWN then + delta = -CAL_STEP_FINE + elseif what == SWITCH_START1 or what == SWITCH_START2 then + calValue = 0 + audioShow() + return true + elseif what == SWITCH_BUTTON1 then + singeSetAudioCalibration(calValue) + toolsClose() + return true + end + if delta ~= 0 then + calValue = math.max(-CAL_LIMIT, math.min(CAL_LIMIT, calValue + delta)) + audioShow() + return true + end + return false + end, + + update = function() + local now = singeGetTicks() + if now - calBeat >= CAL_PERIOD then + calBeat = now + calClickDone = false + calFlashDone = false + end + -- The click goes first; the flash waits out the device queue and the candidate delay, so + -- the two land together only when the candidate matches the machine. + if not calClickDone then + calClickDone = true + soundPlay(SND_CLICK) + end + if not calFlashDone and (now - calBeat >= singeGetAudioLatency() + calValue) then + calFlashDone = true + menuElement("toolAudio"):SetClass("flash", true) + elseif calFlashDone then + menuElement("toolAudio"):SetClass("flash", false) + end + end, + + finish = function() + menuElement("toolAudio"):SetClass("flash", false) + end, +} + + +-- ===== 2. Input test ===== + +-- Every device the engine can see and what it is doing right now. Singe opens every controller as +-- a gamepad, an unrecognised one through a mapping it writes itself, so there is one family to +-- report rather than two. The switch line shows what the game would actually receive, which is the +-- question being asked when a button "does nothing". +local inputLast = "none yet" +local inputCount = 0 + + +-- The engine sets every switch as a SWITCH_ global, so the names come from there rather than from a +-- second list here that could fall behind it. +local SWITCH_NAMES = {} +for name, value in pairs(_G) do + if type(name) == "string" and type(value) == "number" and name:sub(1, 7) == "SWITCH_" then + SWITCH_NAMES[value] = name + end +end + + +TOOLS[#TOOLS + 1] = { + id = "toolInput", + name = "Input Test", + help = "Controllers, keyboard, mice and light guns, live.", + + begin = function() + inputLast = "none yet" + inputCount = 0 + end, + + input = function(what) + inputCount = inputCount + 1 + inputLast = string.format("%s (%d)", SWITCH_NAMES[what] or "unnamed", what) + -- Button 2 is how every other tool backs out, so it is reported and then allowed through. + return false + end, + + update = function() + local rows = {} + rows[#rows + 1] = string.format("
Last switch: %s   count: %d
", inputLast, inputCount) + rows[#rows + 1] = string.format("
Gamepads: %d   Mice: %d   Stick drives mouse: %s
", + controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled())) + for slot = 0, SINGE_MAX_CONTROLLERS - 1 do + if controllerIsValid(slot) then + local axes = {} + for a = 0, SINGE_CONTROLLER_AXES - 1 do + axes[#axes + 1] = string.format("%d", controllerGetAxis(slot, a)) + end + local buttons = {} + -- controllerGetButton wants the framework's own code for the button, not SDL's + -- index, so it is built here the way Framework.singe builds GAMEPAD_0. + for b = 0, SINGE_CONTROLLER_BUTTONS - 1 do + if controllerGetButton(slot, SINGE_GAMEPAD_BASE + slot * SINGE_GAMEPAD_STRIDE + SINGE_GAMEPAD_BUTTON_OFFSET + b) then + buttons[#buttons + 1] = tostring(b) + end + end + rows[#rows + 1] = string.format("
%d: %s
", slot, controllerGetName(slot)) + rows[#rows + 1] = string.format("
axes %s   buttons %s
", + table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-") + end + end + for m = 0, mouseHowMany() - 1 do + local x, y = mouseGetPosition(m) + rows[#rows + 1] = string.format("
mouse %d: %s at %d, %d
", m, mouseGetName(m), x, y) + end + set("inputRows", table.concat(rows)) + end, +} + + +-- ===== 3. System information ===== + +-- Everything the trace header knows, on one screen. A photograph of this answers most of a bug +-- report without anyone having to find trace.txt, which is the point of it. +TOOLS[#TOOLS + 1] = { + id = "toolSystem", + name = "System Information", + help = "What this machine is and what the engine chose to run on.", + + begin = function() + local i = singeGetSystemInfo() + local rows = {} + local function line(label, value) + rows[#rows + 1] = string.format("
%s%s
", label, tostring(value)) + end + line("Singe", i.version) + line("System", i.os) + line("Processor", i.cpu) + line("Renderer", i.renderer) + line("3D device", i.gpu) + line("Video", i.decoder) + line("Audio", i.audio) + line("SoundFont", i.soundFont) + line("MIDI", i.midi) + line("Window", string.format("%d x %d, canvas %d x %d, overlay %d x %d", + i.windowWidth, i.windowHeight, i.canvasWidth, i.canvasHeight, overlayGetWidth(), overlayGetHeight())) + line("Data", i.dataPath) + set("systemRows", table.concat(rows)) + end, +} + + +-- ===== 4. Light gun ===== + +-- Where the gun is pointing, against the picture it is pointing at. A cabinet needs this every +-- time the gun or the monitor moves, and nothing else in the engine shows the mapping from the +-- gun's own coordinates to the canvas. +-- The targets: one in the middle and one in each corner, set in far enough that a gun that is +-- slightly out still puts its shot on the screen beside them. +local GUN_SHOTS_KEPT = 12 +local GUN_INSET = 24 +local GUN_RING = 14 +local GUN_CENTRE = 2 +local GUN_CROSS = 8 +local gunShots = {} +local gunLast = "none yet" + + +TOOLS[#TOOLS + 1] = { + id = "toolGun", + name = "Light Gun", + help = "Aim and fire: see where the shot actually lands.", + + begin = function() + gunShots = {} + gunLast = "none yet" + end, + + input = function(what) + if what == SWITCH_BUTTON1 then + local x, y = mouseGetPosition(0) + gunShots[#gunShots + 1] = { x = x, y = y } + while #gunShots > GUN_SHOTS_KEPT do + table.remove(gunShots, 1) + end + gunLast = string.format("%d, %d", x, y) + return true + elseif what == SWITCH_START1 or what == SWITCH_START2 then + gunShots = {} + return true + end + return false + end, + + update = function() + local w, h = overlayGetWidth(), overlayGetHeight() + -- A target in the middle and one in each corner: a gun that is square on the picture puts + -- its shots on them, and one that is not shows which way it is out. + overlayClear() + colorForeground(90, 90, 90) + overlayBox(1, 1, w - 2, h - 2) + overlayLine(w / 2, 0, w / 2, h) + overlayLine(0, h / 2, w, h / 2) + colorForeground(255, 255, 0) + for _, p in ipairs({ { w / 2, h / 2 }, { GUN_INSET, GUN_INSET }, { w - GUN_INSET, GUN_INSET }, + { GUN_INSET, h - GUN_INSET }, { w - GUN_INSET, h - GUN_INSET } }) do + overlayCircle(p[1], p[2], GUN_RING) + overlayCircle(p[1], p[2], GUN_CENTRE) + end + colorForeground(255, 60, 60) + for _, shot in ipairs(gunShots) do + overlayLine(shot.x - GUN_CROSS, shot.y, shot.x + GUN_CROSS, shot.y) + overlayLine(shot.x, shot.y - GUN_CROSS, shot.x, shot.y + GUN_CROSS) + end + set("gunLast", gunLast) + set("gunCount", tostring(#gunShots)) + end, + + finish = function() + overlayClear() + end, +} + + +-- ===== 5. Sound test ===== + +-- Which speaker is which, and how loud each kind of sound is. A cabinet with its wires crossed +-- sounds perfectly fine until something is meant to come from one side. +local soundStep = 0 +local soundNames = { "left speaker", "right speaker", "both speakers" } +local soundPans = { -1.0, 1.0, 0.0 } + + +TOOLS[#TOOLS + 1] = { + id = "toolSound", + name = "Sound Test", + help = "Left, right, both, and the disc's own audio tracks.", + + begin = function() + soundStep = 0 + set("soundWhich", "press Button 1") + local tracks = {} + for t = 0, discGetAudioTracks() - 1 do + local code = discGetLanguage(t) + tracks[#tracks + 1] = string.format("%d:%s", t, (code ~= "" and code ~= "unk") and code or "unnamed") + end + set("soundTracks", (#tracks > 0) and table.concat(tracks, "   ") or "the disc has none") + end, + + input = function(what) + if what == SWITCH_BUTTON1 then + soundStep = (soundStep % #soundNames) + 1 + soundSetPan(SND_CLICK, soundPans[soundStep]) + soundPlay(SND_CLICK) + set("soundWhich", soundNames[soundStep]) + return true + elseif what == SWITCH_LEFT or what == SWITCH_RIGHT then + -- Walk the disc's audio tracks, which is how a multi language release is checked. + local count = discGetAudioTracks() + if count > 1 then + local next = (discGetAudioTrack() + ((what == SWITCH_RIGHT) and 1 or (count - 1))) % count + discSetAudioTrack(next) + set("soundWhich", string.format("disc track %d (%s)", next, discGetLanguage(next))) + end + return true + end + return false + end, + + finish = function() + soundSetPan(SND_CLICK, 0.0) + end, +} + + +-- ===== 6. Display ===== + +-- The monitor, and the picture's place on it. Scale, shift and rotation are live, so the operator +-- sees what they are doing, and singeSaveGeometry keeps them in machine.cfg beside the audio delay +-- -- once for the cabinet rather than once for every game. +-- +-- The test pattern: a 16 by 12 grid, which is square on a 4:3 monitor and on a 16:9 one when the +-- picture is letterboxed into it, and an eight step greyscale wedge a thirty-second of the width +-- per step. +local FULL_TURN = 360 +local DISP_COLUMNS = 16 +local DISP_ROWS = 12 +local DISP_STEPS = 8 +local DISP_STEP_WIDE = 32 +local DISP_WEDGE = 16 + + +local function displayShow() + local x, y = vldpGetShift() + set("dispValues", string.format("scale %d%%   shift %d, %d   rotation %d", + vldpGetScale(), x, y, vldpGetRotate())) +end + + +TOOLS[#TOOLS + 1] = { + id = "toolDisplay", + name = "Display", + help = "Size, position and rotation of the picture, and a test pattern.", + + begin = function() + displayShow() + set("dispSaved", "") + end, + + input = function(what) + local x, y = vldpGetShift() + if what == SWITCH_LEFT then + vldpSetShift(math.max(SINGE_SHIFT_MIN, x - 1), y) + elseif what == SWITCH_RIGHT then + vldpSetShift(math.min(SINGE_SHIFT_MAX, x + 1), y) + elseif what == SWITCH_UP then + vldpSetShift(x, math.max(SINGE_SHIFT_MIN, y - 1)) + elseif what == SWITCH_DOWN then + vldpSetShift(x, math.min(SINGE_SHIFT_MAX, y + 1)) + elseif what == SWITCH_BUTTON3 then + vldpSetScale(math.max(SINGE_SCALE_MIN, vldpGetScale() - 1)) + elseif what == SWITCH_BUTTON4 then + vldpSetScale(math.min(SINGE_SCALE_MAX, vldpGetScale() + 1)) + elseif what == SWITCH_COIN1 then + vldpSetRotate((vldpGetRotate() + SINGE_ROTATE_STEP) % FULL_TURN) + elseif what == SWITCH_START1 or what == SWITCH_START2 then + vldpSetScale(SINGE_SCALE_MAX) + vldpSetShift(0, 0) + vldpSetRotate(0) + elseif what == SWITCH_BUTTON1 then + singeSaveGeometry() + set("dispSaved", "kept for this machine") + return true + else + return false + end + displayShow() + set("dispSaved", "") + return true + end, + + update = function() + -- A grid to square the picture up with, a bar of greys to set brightness by, and a frame + -- one pixel inside the edge: if any of the frame is missing, the monitor is overscanning. + local w, h = overlayGetWidth(), overlayGetHeight() + overlayClear() + colorForeground(60, 60, 60) + for gx = 0, w, math.floor(w / DISP_COLUMNS) do + overlayLine(gx, 0, gx, h) + end + for gy = 0, h, math.floor(h / DISP_ROWS) do + overlayLine(0, gy, w, gy) + end + -- The greyscale wedge. overlayBox draws an outline, so each step is filled by its own + -- columns; a monitor with its brightness wrong loses the dark end or the light one. + local step = math.floor(w / DISP_STEP_WIDE) + local first = math.floor((w - DISP_STEPS * step) / 2) + local top = math.floor(h / 2) - DISP_WEDGE + local bottom = math.floor(h / 2) + DISP_WEDGE + for i = 0, DISP_STEPS - 1 do + local level = math.floor(i * 255 / (DISP_STEPS - 1)) + local left = first + i * step + colorForeground(level, level, level) + for x = left, left + step - 1 do + overlayLine(x, top, x, bottom) + end + end + -- Two frames, one inside the other: a monitor that overscans eats the outer one first. + colorForeground(255, 255, 255) + overlayBox(0, 0, w - 1, h - 1) + overlayBox(1, 1, w - 2, h - 2) + end, + + finish = function() + overlayClear() + end, +} + + +-- ===== 7. Disc test ===== + +-- How long the disc takes to find a frame. A rip with its keyframes far apart seeks slowly, and +-- the only symptom in a game is that everything feels heavy; this measures it directly. +-- Twelve seeks spread over the disc, and a seek that has not landed in three seconds has failed +-- rather than been slow. +local DISC_SEEKS = 12 +local DISC_SEEK_LIMIT = 3000 +local discResults = {} +local discPending = nil +local discWorst = 0 + + +TOOLS[#TOOLS + 1] = { + id = "toolDisc", + name = "Disc Test", + help = "Seek timing: how quickly the disc finds a frame.", + + begin = function() + discResults = {} + discPending = nil + discWorst = 0 + set("discRows", "
Button 1 runs the test.
") + end, + + input = function(what) + if what == SWITCH_BUTTON1 and not discPending then + discResults = {} + discWorst = 0 + discPending = { step = 0, started = 0 } + return true + end + return false + end, + + update = function() + if not discPending then + return + end + if discPending.started == 0 then + -- Spread the targets over the whole disc rather than clustering, so a framefile's + -- segment changes are included in what is measured. + local total = discGetFrameCount() + discPending.step = discPending.step + 1 + discPending.target = math.floor((discPending.step / DISC_SEEKS) * math.max(1, total - 1)) + discPending.started = singeGetTicks() + discSearch(discPending.target) + return + end + if discGetFrame() == discPending.target or singeGetTicks() - discPending.started > DISC_SEEK_LIMIT then + local took = singeGetTicks() - discPending.started + discResults[#discResults + 1] = string.format("
frame %d took %d ms
", discPending.target, took) + discWorst = math.max(discWorst, took) + discPending.started = 0 + if discPending.step >= DISC_SEEKS then + discResults[#discResults + 1] = string.format("
worst %d ms over %d seeks
", discWorst, discPending.step) + discPending = nil + end + set("discRows", table.concat(discResults)) + end + end, + + finish = function() + discPending = nil + end, +} + + +-- ===== 8. Saved data ===== + +-- What each game has kept, and a way to throw it away. High scores are the usual reason: an +-- operator wants the board cleared without deleting the game. Each game saves into its own data +-- directory, so they are found by looking rather than by being told. +local saveDirs = {} +local saveSelected = 1 + + +local function savedShow() + local rows = {} + if #saveDirs == 0 then + rows[#rows + 1] = "
No game has saved anything yet.
" + end + for i, entry in ipairs(saveDirs) do + local class = (i == saveSelected) and " class='selected'" or "" + rows[#rows + 1] = string.format("%s   %d bytes", class, entry.name, entry.size) + end + set("savedRows", table.concat(rows)) +end + + +TOOLS[#TOOLS + 1] = { + id = "toolSaved", + name = "Saved Data", + help = "What each game has kept, and clearing it.", + + begin = function() + saveDirs = {} + saveSelected = 1 + -- Every game's data directory is one below the root the engine was given. + -- lfs.dir hands back its iterator and the directory it walks; the iterator is useless + -- without it, so both are kept. pcall because the root need not exist yet. + local root = singeGetSystemInfo().dataRoot + local ok, iter, state = pcall(lfs.dir, root) + if ok then + for name in iter, state do + if name ~= "." and name ~= ".." then + local file = root .. name .. "/save.json" + local attr = lfs.attributes(file) + if attr and attr.mode == "file" then + saveDirs[#saveDirs + 1] = { name = name, path = file, size = attr.size } + end + end + end + end + table.sort(saveDirs, function(a, b) return a.name < b.name end) + savedShow() + end, + + input = function(what) + if #saveDirs == 0 then + return false + end + if what == SWITCH_UP then + saveSelected = (saveSelected == 1) and #saveDirs or (saveSelected - 1) + savedShow() + return true + elseif what == SWITCH_DOWN then + saveSelected = (saveSelected == #saveDirs) and 1 or (saveSelected + 1) + savedShow() + return true + elseif what == SWITCH_BUTTON3 then + -- Button 3 rather than Button 1: Button 1 is "choose" everywhere else, and this + -- throws away somebody's high scores. + os.remove(saveDirs[saveSelected].path) + table.remove(saveDirs, saveSelected) + saveSelected = math.max(1, math.min(saveSelected, #saveDirs)) + savedShow() + return true + end + return false + end, +} + + +-- ===== 9. MIDI ===== + +-- The ports this machine has, and whether anything is coming in or going out. Nothing is opened +-- until this tool asks, so a cabinet that never touches MIDI never pays for it. +-- Middle C on channel 1 at a plain mezzo-forte: loud enough to hear on any instrument, and on +-- the channel a synthesiser is most likely to have something assigned to. +local MIDI_TEST_CHANNEL = 1 +local MIDI_TEST_NOTE = 60 +local MIDI_TEST_VELOCITY = 100 +local MIDI_SEEN_MAX = 8 +local midiIn, midiOut = -1, -1 +local midiSeen = {} +local midiDirty = false + + +local function midiShow() + local rows = {} + rows[#rows + 1] = string.format("
%d in, %d out
", midiInputCount(), midiOutputCount()) + for i = 0, midiOutputCount() - 1 do + rows[#rows + 1] = string.format("
out %d%s %s
", i, (i == midiOut) and " *" or "", midiOutputName(i)) + end + for i = 0, midiInputCount() - 1 do + rows[#rows + 1] = string.format("
in  %d%s %s
", i, (i == midiIn) and " *" or "", midiInputName(i)) + end + for _, m in ipairs(midiSeen) do + rows[#rows + 1] = string.format("
received %s
", m) + end + set("midiRows", table.concat(rows)) +end + + +function onMidiMessage(status, data1, data2, bytes) + midiSeen[#midiSeen + 1] = string.format("%02X %02X %02X", status, data1, data2) + while #midiSeen > MIDI_SEEN_MAX do + table.remove(midiSeen, 1) + end + midiDirty = true +end + + +TOOLS[#TOOLS + 1] = { + id = "toolMidi", + name = "MIDI Ports", + help = "List the ports, send a note, watch what arrives.", + + begin = function() + midiSeen = {} + midiDirty = false + if midiOutputCount() > 0 and not midiIsOutputOpen() then + midiOut = midiOpenOutput(0) and 0 or -1 + end + if midiInputCount() > 0 and not midiIsInputOpen() then + midiIn = midiOpenInput(0) and 0 or -1 + end + midiShow() + end, + + input = function(what) + if what == SWITCH_BUTTON1 and midiIsOutputOpen() then + midiNoteOn(MIDI_TEST_CHANNEL, MIDI_TEST_NOTE, MIDI_TEST_VELOCITY) + midiNoteOff(MIDI_TEST_CHANNEL, MIDI_TEST_NOTE) + return true + elseif what == SWITCH_BUTTON3 then + midiRescan() + midiShow() + return true + end + return false + end, + + update = function() + if midiDirty then + midiDirty = false + midiShow() + end + end, + + finish = function() + midiCloseInput() + midiCloseOutput() + midiIn, midiOut = -1, -1 + end, +} + + +-- ===== 10. Frame statistics ===== + +TOOLS[#TOOLS + 1] = { + id = "toolStats", + name = "Frame Statistics", + help = "The developer's overlay: frame time, what is alive, what the disc is doing.", + + begin = function() + set("statsState", statsIsEnabled() and "on" or "off") + end, + + input = function(what) + if what == SWITCH_BUTTON1 then + statsEnable(not statsIsEnabled()) + set("statsState", statsIsEnabled() and "on" or "off") + return true + end + return false + end, +} diff --git a/assets/menu.rcss b/assets/menu.rcss index f7ccef0f8..a46584552 100644 --- a/assets/menu.rcss +++ b/assets/menu.rcss @@ -124,8 +124,16 @@ body { margin: 0; } -/* Audio delay calibration: an opaque page over everything, white for the frame of the flash. */ -#calibration { +/* .hidden and .tool are one class each, and the pages that let the overlay through wear two, so a + page taken out of view has to be said again here or its display wins. */ +.tool.hidden, +.tool.clear.hidden { + display: none; +} + +/* The service tools: one opaque page over everything, the same for all of them. The audio delay + tool alone turns the page white for the frame of its flash, so the colours are named twice. */ +.tool { position: absolute; left: 0; top: 0; @@ -136,35 +144,107 @@ body { box-sizing: border-box; background-color: #000000; color: #ffffff; + overflow-y: auto; } -#calibration.flash { +.tool.flash { background-color: #ffffff; color: #000000; } -#calibration h1 { +.tool h1 { font-size: 24dp; color: #ffffff; margin-bottom: 20dp; } -#calibration.flash h1 { +.tool.flash h1 { color: #000000; } -#calibration p { +.tool p { margin-bottom: 20dp; } -#calibration p.value { +.tool p.value { margin-bottom: 4dp; } -#calibration p.value + p.keys { +/* The key hints stand away from whatever the tool put above them, and close together among + themselves when a tool needs more than one line of them. */ +.tool p.keys { margin-top: 20dp; + margin-bottom: 4dp; + color: #8fa2c4; } -#calibration p.keys { +.tool p.keys + p.keys { + margin-top: 0; +} + +/* The rows a tool fills in for itself: a heading row and an indented detail row under it. Text + with nowhere to break -- a long path -- wraps mid-word rather than running off the edge, but an + ordinary word is left whole. */ +.tool .row { + margin-bottom: 2dp; + word-break: break-word; +} + +.tool .sub { + margin-left: 20dp; + margin-bottom: 2dp; + font-size: 12dp; + color: #c8d2e6; + word-break: break-word; +} + +/* A label beside its value. The two are columns rather than one run of text, so a value too long + for the line wraps under itself instead of under the label. */ +.tool .pair { + display: flex; + margin-bottom: 2dp; +} + +.tool .pair .label { + flex: 0 0 110dp; + color: #8fa2c4; +} + +.tool .pair .detail { + flex: 1 1 0; + word-break: break-word; +} + +/* The chosen line of a list the tool draws, in the game list's gold. */ +.tool .selected { + color: #ffd35a; +} + +/* Two tools draw their own picture into the overlay, which is underneath the document: the light + gun's targets and the display's test pattern. Those pages let it through and gather their text + into a strip along the bottom, between the two lower targets, so nothing worth aiming at is + covered. */ +.tool.clear { + background-color: transparent; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; +} + +.tool.clear h1 { + font-size: 16dp; margin-bottom: 4dp; } + +.tool.clear h1, +.tool.clear p { + background-color: #000000c0; + padding: 1dp 8dp; + margin-bottom: 4dp; +} + +/* The help under the list of tools, away from the list itself. */ +#toolHelp { + margin-top: 12dp; +} diff --git a/docs/Manual.adoc b/docs/Manual.adoc index cba5795b7..aae03adbf 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -88,8 +88,8 @@ a line of key hints along the bottom. Up and down move the selection one game at a time and left and right move it 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 audio delay calibration -screen (see <>). With a mouse, a +`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 @@ -100,7 +100,79 @@ A GUI needs the GPU device the 3D scene uses, so on a machine without one `guiNew` fails and the menu cannot start. `Singe/MenuClassic.singe` is the previous menu, drawn with sprites and `fontPrint` straight into the overlay, kept for one release for exactly that machine: edit `Menu.sh` (or `Menu.bat`) -to name it in place of `Singe/Menu.singe`. +to name it in place of `Singe/Menu.singe`. It has the audio delay screen and +nothing else; the rest of the service tools need the document. + +[#servicetools] +==== The Service Tools + +The key mapped to `INPUT_SERVICE` opens a list of tools over the game list. +Up and down choose one and button 1 opens it; button 2 backs out of a tool to +the list, and the service key again leaves the tools altogether. Everything +lives in `Singe/Tools.singe`, which the engine extracts beside `Menu.singe`, +and each tool is a page of `Singe/Menu.rml`. Nothing here needs a game to be +running, and nothing changes a game's own files. + +Audio Delay:: +The downstream audio delay, measured against a click and a flash. See +<> for what it is doing and why it +works. + +Input Test:: +Every controller, mouse and gun the engine can see, live: the last switch the +game would have received and how many have arrived, each pad's name with its +six axes and the buttons held down, and each mouse's name and position. This +is the screen that answers "the button does nothing" -- either the switch +arrives and the game ignores it, or it never arrives at all. + +System Information:: +The engine's version, the operating system, the processor and memory, the +renderer and the 3D device, which video decoders are in play, the audio +formats built in, the SoundFont, the MIDI state, the window, canvas and +overlay sizes, and the data directory. A photograph of this page answers most +of a bug report. + +Light Gun:: +Targets in the centre and at each corner of the overlay, over whatever the +disc is showing. Button 1 marks where the gun actually pointed, keeping the +last twelve shots; start clears them. A gun that is square on the picture puts +its crosses on the targets, and one that is not shows which way it is out. + +Sound Test:: +Button 1 plays the click through the left speaker, then the right, then both, +which is the whole of a crossed-wires check. It also lists the disc's own +audio tracks, and left and right switch between them. + +Display:: +The picture's size, position and rotation, changed live with the arrows, +buttons 3 and 4, and coin 1, over a test pattern: a 16 by 12 grid to square +the picture up with, an eight step greyscale wedge to set brightness by, and +two frames one pixel apart at the very edge -- a monitor that overscans eats +the outer one first. Button 1 keeps the values for this machine, in the same +`machine.cfg` the audio delay uses, so they apply to every game rather than +being set once per game. Start puts everything back. + +Disc Test:: +Twelve seeks spread over the whole disc, each timed. A rip whose keyframes are +far apart seeks slowly, and the only symptom in a game is that everything +feels heavy; this measures it directly and reports the worst of the twelve. +A seek that has not landed in three seconds is counted as that. + +Saved Data:: +What each game has kept, found by looking through the data root rather than by +being told, with the size of each. Button 3 -- not button 1, which is "choose" +everywhere else -- throws away the selected game's save. Clearing a high score +table without deleting the game is the usual reason. + +MIDI Ports:: +The input and output ports this machine has, which of them Singe has opened, +and the last few messages that arrived. Button 1 sends a note, button 3 looks +for ports again after something has been plugged in. Nothing is opened until +this tool asks, so a cabinet that never touches MIDI never pays for it. + +Frame Statistics:: +Turns the developer's overlay on and off. It stays on into the game, in the +top left corner. See <>. [[controls]] === Customizing the Controls @@ -334,12 +406,18 @@ names one, and `deterministic = false` leaves the option off. . The built in default. . The settings file. . The game's `games.dat` entry, for the handful of settings it carries (see <>). +. `machine.cfg`, for the audio delay and the picture geometry the service tools + save (see <>). . The command line, which always wins. A settings file is a set of defaults, in other words, not something you typed: if a `games.dat` entry gives a game a resolution or a Sinden border, that entry still describes the game better than a machine-wide file does. Anything actually -typed on the command line beats both. +typed on the command line beats both. `machine.cfg` sits just under the command +line because somebody adjusted the picture while looking at this monitor, which +is better evidence than any file written elsewhere; the audio delay is the +exception to the ordering entirely, being added to the game's delay rather than +replacing it. Options that name the game (`--framefile`, `--disc`, `--entry`), name a directory (`--gamedir`, `--datadir`), or have to act before the file could be @@ -871,10 +949,11 @@ Singe/ Support files extracted by the engine Menu.singe The bundled game menu Menu.rml The menu's RmlUi document menu.rcss The menu's style sheet, on top of gui.rcss + Tools.singe The menu's service tools (see The Service Tools) MenuClassic.singe The previous, overlay-drawn menu (see The Bundled Menu) gui.rcss The shipped GUI theme controls.cfg.example Template for input mappings - click.wav Used by the menu's audio delay calibration + click.wav Used by the menu's audio delay and sound tests Manual.pdf This manual ActionMax/ One game games.dat Menu entries for the games in this directory @@ -883,6 +962,7 @@ ActionMax/ One game sprite_*.png, sound_*.wav, font_*.ttf DLe.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 ---- @@ -3107,7 +3187,9 @@ available to `controls.cfg` and to `Framework.singe` alike: | `SINGE_TRIGGER_THRESHOLD` | The analogue trigger threshold in those same raw units; `0` when the triggers are on `SINGE_DEAD_ZONE`, which is the default. | `SINGE_LEGACY_SPRITE_ARGS` | True when the game asked for the 2.10 sprite argument order. | `SINGE_DISC` | True when the game has a laserdisc; false when the canvas is the world. -| `SINGE_GAMEPAD_BASE`, `SINGE_GAMEPAD_STRIDE`, `SINGE_AXIS_STRIDE`, `SINGE_GAMEPAD_BUTTON_OFFSET`, `SINGE_MOUSE_BASE`, `SINGE_MOUSE_STRIDE`, `SINGE_MAX_CONTROLLERS`, `SINGE_MAX_MICE` | Layout of the controller and mouse input codes; `Framework.singe` builds the `GAMEPAD_N` and `MOUSE_N` tables from them. +| `SINGE_GAMEPAD_BASE`, `SINGE_GAMEPAD_STRIDE`, `SINGE_AXIS_STRIDE`, `SINGE_GAMEPAD_BUTTON_OFFSET`, `SINGE_MOUSE_BASE`, `SINGE_MOUSE_STRIDE`, `SINGE_MAX_CONTROLLERS`, `SINGE_MAX_MICE`, `SINGE_CONTROLLER_AXES`, `SINGE_CONTROLLER_BUTTONS` | Layout of the controller and mouse input codes, and how many axes and buttons a pad has; `Framework.singe` builds the `GAMEPAD_N` and `MOUSE_N` tables from them, and a service screen walks a pad with them. + +| `SINGE_SCALE_MIN`, `SINGE_SCALE_MAX`, `SINGE_SHIFT_MIN`, `SINGE_SHIFT_MAX`, `SINGE_ROTATE_STEP` | What `vldpSetScale`, `vldpSetShift` and `vldpSetRotate` will take. Clamp to these rather than to your own copies: a shift out of range ends the script. |=== `Framework.singe` adds the `SCANCODE` and `MODIFIER` tables (SDL's key and @@ -3172,16 +3254,17 @@ of audio arriving early. ==== Calibrating from the menu 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. 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 the click happen -together, then press button 1 to save. Start resets to zero and button 2 (or -the service key again) cancels. The value is stored in `audio.cfg` in the -data root and applied by the engine to every game on that machine, whether -launched from the menu or from the command line; it is added to any per-game -`AUDIO_DELAY`. +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 +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 +the click happen together, then press button 1 to save. Start resets to zero +and button 2 (or the service key again) cancels. The value is stored in +`machine.cfg` in the data root and applied by the engine to every game on that +machine, whether launched from the menu or from the command line; it is added +to any per-game `AUDIO_DELAY`. Recalibrate after changing speakers, headphones, or displays. The screen works because the click and the disc audio share one mixer device @@ -5312,7 +5395,7 @@ for track = 0, discGetAudioTracks() - 1 do end ---- -[#discgetframe] +[#discgetsubtitlelanguage] ==== discGetSubtitleLanguage [source,text] @@ -5331,6 +5414,7 @@ The language a subtitle track inside the disc's own container is labelled with, *Since:* 3.00. *See also:* <>, <> +[#discgetsubtitletracks] ==== discGetSubtitleTracks [source,text] @@ -5360,6 +5444,7 @@ for track = 0, discGetSubtitleTracks() - 1 do end ---- +[#discgetframe] ==== discGetFrame [source,text] @@ -5389,6 +5474,31 @@ function onOverlayUpdate() end ---- +[#discgetframecount] +==== discGetFrameCount + +[source,text] +---- +frames = discGetFrameCount() +---- + +The number of frames on the disc, which is one past the highest frame +<> will find. A framefile answers for the whole disc +rather than for the segment playing, so the count does not change as segments +come and go. Without a disc it answers `0`. + +*Returns:* integer frame count, `0` without a disc. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Seek to the middle of the disc, wherever that is. +discSearch(math.floor(discGetFrameCount() / 2)) +---- + [#discgetheight] ==== discGetHeight @@ -13837,7 +13947,7 @@ end milliseconds = singeGetAudioCalibration() ---- -Returns the per-machine audio delay in effect, in milliseconds: the value the menu's calibration screen saved to `audio.cfg` in the data root, or one set since by `singeSetAudioCalibration`. Zero when the machine has never been calibrated. It applies to every game on the machine, on top of the per-game delay from `singeGetAudioDelay`. +Returns the per-machine audio delay in effect, in milliseconds: the value the menu's calibration screen saved to `machine.cfg` in the data root, or one set since by `singeSetAudioCalibration`. Zero when the machine has never been calibrated. It applies to every game on the machine, on top of the per-game delay from `singeGetAudioDelay`. *Returns:* integer milliseconds. @@ -14020,6 +14130,49 @@ DIR = singeGetScriptPath():match("(.*[/\\])") or "./" titleSprite = spriteLoad(DIR .. "images/title.png") ---- +[#singegetsysteminfo] +==== singeGetSystemInfo + +[source,text] +---- +info = singeGetSystemInfo() +---- + +Everything the trace header knows about this machine and this run, in one +table. It is meant for a service screen and for bug reports: the answer to +"what was it running on" without asking the player to find a log. + +*Returns:* a table with these fields, all strings except the four sizes: + +* `version` -- the engine version, as `SINGE_VERSION_STRING`. +* `os` -- the operating system name and release, and the architecture. +* `cpu` -- the processor, its core count and the memory in megabytes. +* `renderer` -- the name of SDL's 2D renderer in use. +* `gpu` -- the SDL_GPU driver behind the 3D scene, or a note that 3D is + unavailable. +* `decoder` -- which hardware video decoders the build will try, in words. +* `audio` -- the audio formats compiled in, comma separated. +* `soundFont` -- the SoundFont MIDI playback is using, or why there is none. +* `midi` -- the MIDI ports open, or a note that none have been asked for. +* `dataPath` -- this game's data directory. +* `dataRoot` -- the data directory the engine was given, one above `dataPath`, + which every game on the machine writes under. +* `windowWidth`, `windowHeight` -- integers, the window in pixels. +* `canvasWidth`, `canvasHeight` -- integers, the canvas in pixels. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A one-key diagnostic screen. +local info = singeGetSystemInfo() +fontPrint(10, 10, "Singe " .. info.version .. " on " .. info.os) +fontPrint(10, 30, info.cpu) +fontPrint(10, 50, info.renderer .. " / " .. info.gpu) +---- + [#singegetticks] ==== singeGetTicks @@ -14124,6 +14277,42 @@ function onKeyPressed(keysym, scancode) end ---- +[#singesavegeometry] +==== singeSaveGeometry + +[source,text] +---- +singeSaveGeometry() +---- + +Keeps the picture's current scale, shift and rotation for this machine. They +go into `machine.cfg` in the data root, beside the audio delay, and the engine +applies them to every game it starts afterwards, from the menu or from the +command line, and across `singeReload`. An option actually typed on the command +line wins: `--scalefactor` overrides the saved scale, `--shiftx` or `--shifty` +the saved shift, `--rotate` the saved rotation. The file is written whole, so +the audio delay is kept as it stands. + +This is a cabinet setting, not a game setting: it is how a monitor is squared +up once rather than once per game. The Display tool in the bundled menu is +exactly this call behind button 1. + +*Returns:* nothing. + +*Since:* 3.00. +*See also:* <>, <>, <>, <> + +.Example +[source,lua] +---- +-- The operator is happy with the picture: keep it for the cabinet. +function onInputPressed(input) + if input == SWITCH_BUTTON1 then + singeSaveGeometry() + end +end +---- + [#singescreenshot] ==== singeScreenshot @@ -14160,7 +14349,7 @@ end singeSetAudioCalibration(milliseconds) ---- -Sets the per-machine audio delay, applies it at once, and writes it to `audio.cfg` in the data root so every game on the machine picks it up. The menu's calibration screen calls it; a game with its own service menu may too. The value is positive when the audio is heard later than the device reports; the engine delays video presentation by that much. Values outside `-1000` to `1000` abort the script. If `audio.cfg` cannot be written the value still applies for this run and a message goes to the console. +Sets the per-machine audio delay, applies it at once, and writes it to `machine.cfg` in the data root so every game on the machine picks it up. The menu's calibration screen calls it; a game with its own service menu may too. The value is positive when the audio is heard later than the device reports; the engine delays video presentation by that much. Values outside `-1000` to `1000` abort the script. If `machine.cfg` cannot be written the value still applies for this run and a message goes to the console. *Parameters:* @@ -16098,6 +16287,7 @@ so it never lands in a screenshot. Leave it off in a shipped game. Nothing stops you putting it behind a key in your own service menu. +[#statsenable] ==== statsEnable [source,text] @@ -17934,6 +18124,32 @@ else end ---- +[#vldpgetshift] +==== vldpGetShift + +[source,text] +---- +x, y = vldpGetShift() +---- + +The picture's offset within the window as percentages, the `--shiftx` and +`--shifty` options or whatever <> last applied, and +`0, 0` when neither has been given. At the extreme a shift fills exactly the +room `--scalefactor` left, so `100` with the scale at `100` moves nothing. + +*Returns:* two integers, each `-100` to `100`. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Report where the operator has put the picture. +local x, y = vldpGetShift() +fontPrint(10, 10, string.format("shift %d, %d", x, y)) +---- + [#vldpgetwidth] ==== vldpGetWidth @@ -18164,6 +18380,44 @@ function onInputPressed(input) end ---- +[#vldpsetshift] +==== vldpSetShift + +[source,text] +---- +vldpSetShift(x, y) +---- + +Moves the picture within the window, the running equivalent of `--shiftx` and +`--shifty`, and rebuilds the video rectangle so the disc, the overlay, the +GUIs, the 3D scene, the particles, any Sinden border and the mouse mapping all +follow it. The values are percentages of the room `--scalefactor` left, so +with the scale at `100` there is nowhere to move to. A value outside `-100` to +`100` ends the script, unlike <>, which answers +`false`. + +*Parameters:* + +* `x` -- integer, `-100` to `100`. +* `y` -- integer, `-100` to `100`. + +*Returns:* nothing. + +*Since:* 3.00. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Nudge the picture left while the operator holds the key. +function onInputPressed(input) + if input == SWITCH_LEFT then + local x, y = vldpGetShift() + vldpSetShift(math.max(-100, x - 1), y) + end +end +---- + [#vldpsetverbose] ==== vldpSetVerbose diff --git a/src/embedded.h b/src/embedded.h index f4678c5d2..e988ee6cb 100644 --- a/src/embedded.h +++ b/src/embedded.h @@ -33,6 +33,7 @@ #include "generated/controls_cfg.h" #include "generated/settings_cfg.h" #include "generated/Menu_singe.h" +#include "generated/Tools_singe.h" #include "generated/MenuClassic_singe.h" #include "generated/Menu_rml.h" #include "generated/menu_rcss.h" diff --git a/src/frameFile.c b/src/frameFile.c index 87fc31c43..bea402c12 100644 --- a/src/frameFile.c +++ b/src/frameFile.c @@ -246,6 +246,17 @@ int64_t frameFileGetFrame(int32_t frameFileHandle, int32_t videoHandle) { } +int64_t frameFileGetFrameCount(int32_t frameFileHandle) { + FrameFileT *f = _getFrameFile(frameFileHandle, "frameFileGetFrameCount"); + + if (f->count <= 0) { + return 0; + } + + return f->files[f->count - 1].frame + videoGetFrameCount(_openSegment(f, f->count - 1)); +} + + int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer *renderer, bool showCalculated) { int32_t count = 0; int64_t frame = 0; diff --git a/src/frameFile.h b/src/frameFile.h index d0ffaf958..4f56fcf47 100644 --- a/src/frameFile.h +++ b/src/frameFile.h @@ -32,6 +32,10 @@ char *frameFileAudioName(const char *filename, const char *suffix); int64_t frameFileGetFrame(int32_t frameFileHandle, int32_t videoHandle); + +// Frames in the whole disc: where the last segment starts, plus how long it is. Opening that +// segment is the price of the answer, which is why it is asked for rather than kept. +int64_t frameFileGetFrameCount(int32_t frameFileHandle); int32_t frameFileLoad(const char *filename, const char *indexPath, SDL_Renderer *renderer, bool showCalculated); void frameFileQuit(void); void frameFileSeek(int32_t frameFileHandle, int64_t seekFrame, int32_t *videoHandle, int64_t *actualFrame); diff --git a/src/main.c b/src/main.c index 4d341a273..7b5a57d3e 100644 --- a/src/main.c +++ b/src/main.c @@ -276,9 +276,6 @@ static char *_cloneString(const char *string); #ifndef _WIN32 static void _crashHandler(int signalNumber); #endif -static char *_describeAudioDecoders(void); -static char *_describeCpu(void); -static char *_describeOs(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); @@ -381,7 +378,8 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons // Overscan Zoom case 'b': - target = &conf->scaleFactor; + conf->given |= GIVEN_SCALE; + target = &conf->scaleFactor; break; // Show Calculated Frame File Values @@ -510,7 +508,8 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons // Presentation Rotation case 'r': - target = &conf->rotate; + conf->given |= GIVEN_ROTATE; + target = &conf->rotate; break; // Where The Sinden Border Sits @@ -550,7 +549,8 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons // Horizontal Shift case 'X': - target = &conf->shiftX; + conf->given |= GIVEN_SHIFT; + target = &conf->shiftX; break; // X Resolution @@ -562,7 +562,8 @@ static void _applyOptions(const char *exeName, ConfigT *conf, int32_t argc, cons // Vertical Shift case 'Y': - target = &conf->shiftY; + conf->given |= GIVEN_SHIFT; + target = &conf->shiftY; break; // Y Resolution @@ -949,7 +950,7 @@ static void _crashHandler(int signalNumber) { // Which audio formats this build can decode, for a bug report and so a user whose music is silent // can see at a glance whether its format was ever compiled in. The mixer keeps the list. -static char *_describeAudioDecoders(void) { +char *mainDescribeAudioDecoders(void) { char *list = strdup(""); char *grown = NULL; int32_t count = MIX_GetNumAudioDecoders(); @@ -967,7 +968,7 @@ static char *_describeAudioDecoders(void) { // The processor, for a bug report. Every platform keeps the name somewhere different, and none of // them is worth failing over: the core count is always there as a fallback. -static char *_describeCpu(void) { +char *mainDescribeCpu(void) { #ifdef _WIN32 char name[128]; DWORD bytes = sizeof(name); @@ -1019,7 +1020,7 @@ static char *_describeCpu(void) { // The operating system and its version, for a bug report. -static char *_describeOs(void) { +char *mainDescribeOs(void) { #ifdef _WIN32 char release[64]; DWORD bytes = sizeof(release); @@ -1749,9 +1750,9 @@ static void _stopSDL(void) { // 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. static void _traceHeader(const ConfigT *conf, SDL_Renderer *renderer, SDL_GPUDevice *device) { - char *os = _describeOs(); - char *cpu = _describeCpu(); - char *audio = _describeAudioDecoders(); + char *os = mainDescribeOs(); + char *cpu = mainDescribeCpu(); + char *audio = mainDescribeAudioDecoders(); int32_t built = SDL_VERSION; int32_t linked = SDL_GetVersion(); @@ -1789,6 +1790,7 @@ static void _unpackData(const char *exePath, bool absolute) { { "controls.cfg.example", controls_cfg, controls_cfg_len }, { "settings.cfg.example", settings_cfg, settings_cfg_len }, { "Menu.singe", Menu_singe, Menu_singe_len }, + { "Tools.singe", Tools_singe, Tools_singe_len }, { "MenuClassic.singe", MenuClassic_singe, MenuClassic_singe_len }, { "Menu.rml", Menu_rml, Menu_rml_len }, { "menu.rcss", menu_rcss, menu_rcss_len }, diff --git a/src/main.h b/src/main.h index eb051a0ce..b66e8a9e8 100644 --- a/src/main.h +++ b/src/main.h @@ -60,6 +60,11 @@ char *createDataDir(const char *dataDirBase, const char *filename); char *createDataDirFor(const ConfigT *conf); void destroyConf(ConfigT **confPointer); bool isFrameFileName(const char *filename); +// What the trace header says about this machine, for the service tools to show as well. Each one +// allocates; free() what comes back. +char *mainDescribeAudioDecoders(void); +char *mainDescribeCpu(void); +char *mainDescribeOs(void); bool parseSindenString(const char *sindenString, ConfigT *conf); void queueScript(const ConfigT *conf); char *resolveDataDir(const ConfigT *conf); diff --git a/src/singe.c b/src/singe.c index 5efd154b4..9faafb9cb 100644 --- a/src/singe.c +++ b/src/singe.c @@ -61,6 +61,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L); #include "collide.h" #include "decode.h" #include "main.h" +#include "midi.h" #include "midiIo.h" #include "util.h" #include "frameFile.h" @@ -136,7 +137,6 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define ANIMATION_MIN_DELAY_MS 10 // GIFs often carry a zero delay #define SCREENSHOT_MAX 10000 #define VIDEO_SCALE_THROTTLE_MS 15 // vldpSetScale accepts one change this often, as Hypseus does -#define VIDEO_SHIFT_MAX 100 // --shiftx and --shifty at their extreme fill the room --scalefactor leaves #define BEZEL_DIRECTORY "bezels" // Where --bezel looks for its artwork unless --bezeldir names another #define BEZEL_SIDECAR_EXTENSION ".cfg" // The cutout beside the artwork, named after it #define BEZEL_CUTOUT_TABLE "CUTOUT" // The one table the sidecar sets @@ -215,7 +215,7 @@ SDL_COMPILE_TIME_ASSERT(codeGamepadBase, CODE_GAMEPAD_BASE >= SDL_SCANCODE_RESER #define SOUND_DIRECTION_EPSILON 0.0001f // Closer than this to the listener has no direction #define HEIGHTMAP_MAX 1025 // Pixels per side of a heightmap image #define WATCH_INTERVAL_MS 1000 // How often --reload checks the script files -#define AUDIO_CALIBRATION_FILE "audio.cfg" // Per-machine audio delay, in the data root +#define MACHINE_FILE "machine.cfg" // Per-machine audio delay and picture geometry, in the data root #define CONTROLS_FILE "controls.cfg" // Input mappings, built in and overridden per game #define SETTINGS_FILE "settings.cfg" // User options, found the same four ways controls.cfg is #define SETTINGS_PLACES 4 // The four places both of those are looked for @@ -781,12 +781,12 @@ static int32_t _lfsDirIterator(lua_State *L); static void _lfsFillAttributes(lua_State *L, bool directory, int64_t size, int64_t modified); static int32_t _lfsMkdir(lua_State *L); static int32_t _lfsRmdir(lua_State *L); -static int32_t _loadAudioCalibration(void); static void _loadControlMappings(void); static void _loadControlsFile(const char *path); static SDL_Surface *_loadEmbeddedPng(const uint8_t *data, size_t length); static SDL_Texture *_loadEmbeddedTexture(const uint8_t *data, size_t length, SDL_Surface **surface); static void _loadGamepadDatabase(void); +static void _loadMachineSettings(void); static void _logicalRect(SDL_FRect *rect); static int32_t _luaCallOriginal(lua_State *L); static void _luaDie(lua_State *L, const char *method, const char *fmt, ...) __attribute__((format(printf, 3, 4))) __attribute__((noreturn)); @@ -801,6 +801,7 @@ static int32_t _luaPanic(lua_State *L); static int32_t _luaSearcher(lua_State *L); static void _luaTrace(lua_State *L, const char *method, const char *fmt, ...) __attribute__((format(printf, 3, 4))); static int32_t _luaTraceback(lua_State *L); +static char *_machineFilePath(void); static void _mapPointer(float px, float py, int32_t *x, int32_t *y); static void _mapPointerDelta(float dx, float dy, int32_t *xr, int32_t *yr); static void _mapUnknownJoysticks(void); @@ -809,6 +810,7 @@ static float _mixerGain(int32_t volume, int32_t maximum); static int32_t _mouseCode(int32_t device, int32_t button); static void _musicDestroy(MusicT *music); static bool _saveCjson(lua_State *L, const char *name); +static void _saveMachineSettings(int32_t milliseconds); static void _saveCopy(lua_State *L, int32_t index, const char *method, int32_t depth); static void _saveNormalise(lua_State *L, int32_t depth); static void _saveCheckValue(lua_State *L, const char *method, int32_t index); @@ -852,7 +854,6 @@ static void _resetScriptState(void); static void _rmlEscape(char *out, size_t size, const char *text); static void _runScript(bool fatal); static bool _sameLoosePath(const char *a, const char *b); -static void _saveAudioCalibration(int32_t milliseconds); static SDL_Texture *_sceneVideoSource(int32_t player); static void _scorePanelDigits(char *text, size_t size, int32_t value, int32_t digits, bool blank); static bool _scorePanelEnable(bool enabled); @@ -968,6 +969,7 @@ static int32_t apiDiscGetAudioTracks(lua_State *L); static int32_t apiDiscGetSubtitleLanguage(lua_State *L); static int32_t apiDiscGetSubtitleTracks(lua_State *L); static int32_t apiDiscGetFrame(lua_State *L); +static int32_t apiDiscGetFrameCount(lua_State *L); static int32_t apiDiscGetHeight(lua_State *L); static int32_t apiDiscGetLanguage(lua_State *L); static int32_t apiDiscGetState(lua_State *L); @@ -1266,6 +1268,8 @@ static int32_t apiSingeGetAudioCalibration(lua_State *L); static int32_t apiSingeGetAudioDelay(lua_State *L); static int32_t apiSingeGetAudioLatency(lua_State *L); static int32_t apiSingeGetDataPath(lua_State *L); +static int32_t apiSingeGetSystemInfo(lua_State *L); +static int32_t apiSingeSaveGeometry(lua_State *L); static int32_t apiSingeGetHeight(lua_State *L); static int32_t apiSingeGetPauseFlag(lua_State *L); static int32_t apiSingeGetScriptPath(lua_State *L); @@ -1402,7 +1406,9 @@ static int32_t apiVldpSetBlend(lua_State *L); static int32_t apiVldpSetLuma(lua_State *L); static int32_t apiVldpSetMonochrome(lua_State *L); static int32_t apiVldpSetRotate(lua_State *L); +static int32_t apiVldpGetShift(lua_State *L); static int32_t apiVldpSetScale(lua_State *L); +static int32_t apiVldpSetShift(lua_State *L); static int32_t apiVldpSetVerbose(lua_State *L); @@ -2354,8 +2360,8 @@ static void _computeVideoRect(void) { scaledH = rect.h * (float)_global.videoScale / (float)SCALE_FACTOR_MAX; roomX = (rect.w - scaledW) / 2.0f; roomY = (rect.h - scaledH) / 2.0f; - rect.x += roomX + roomX * (float)_global.videoShiftX / (float)VIDEO_SHIFT_MAX; - rect.y += roomY + roomY * (float)_global.videoShiftY / (float)VIDEO_SHIFT_MAX; + rect.x += roomX + roomX * (float)_global.videoShiftX / (float)SHIFT_MAX; + rect.y += roomY + roomY * (float)_global.videoShiftY / (float)SHIFT_MAX; rect.w = scaledW; rect.h = scaledH; @@ -3776,28 +3782,6 @@ static int32_t _lfsRmdir(lua_State *L) { } -// The per-machine audio delay lives beside the data directories, since it is not a property of any game. -static int32_t _loadAudioCalibration(void) { - char *path = utilCreateString("%s%s", _global.conf->dataDirBase, AUDIO_CALIBRATION_FILE); - size_t bytes = 0; - char *data = utilReadFile(path, &bytes); - int32_t value = 0; - - if (data) { - if (sscanf(data, "delay = %d", &value) != 1) { - value = 0; - } - free(data); - } - free(path); - if ((value < -VIDEO_AUDIO_DELAY_MAX) || (value > VIDEO_AUDIO_DELAY_MAX)) { - value = 0; - } - - return value; -} - - // The built-in controls.cfg, then every override in turn (the working directory, above and inside // the data directory, beside the script), each place only once, in a throwaway Lua state that has // the framework but not the API. Leaves the dead zone and the switch mappings behind. @@ -4000,6 +3984,50 @@ static void _loadGamepadDatabase(void) { // The whole logical rectangle: the window, in the coordinates everything is drawn in. +// Everything the service tools keep for this machine, read in one pass: the audio delay and the +// picture geometry. A file missing a key leaves that setting alone, which is why each is looked +// for on its own rather than with one scan of the whole file. +// +// The geometry goes into the configuration rather than straight into the live values, because the +// live ones are set back to the configuration whenever a script starts or reloads; writing there +// makes "start from the command line" mean "start from the command line as this cabinet amends it". +// An option actually typed wins, which is what the GIVEN_ flags are for. +static void _loadMachineSettings(void) { + char *path = _machineFilePath(); + size_t bytes = 0; + char *data = utilReadFile(path, &bytes); + const char *at = NULL; + int32_t delay = 0; + int32_t value = 0; + + free(path); + if (data != NULL) { + at = strstr(data, "delay = "); + if ((at != NULL) && (sscanf(at, "delay = %d", &value) == 1) && (value >= -VIDEO_AUDIO_DELAY_MAX) && (value <= VIDEO_AUDIO_DELAY_MAX)) { + delay = value; + } + at = strstr(data, "scale = "); + if ((at != NULL) && !(_global.conf->given & GIVEN_SCALE) && (sscanf(at, "scale = %d", &value) == 1) && (value >= SCALE_FACTOR_MIN) && (value <= SCALE_FACTOR_MAX)) { + _global.conf->scaleFactor = value; + } + at = strstr(data, "shiftx = "); + if ((at != NULL) && !(_global.conf->given & GIVEN_SHIFT) && (sscanf(at, "shiftx = %d", &value) == 1) && (value >= SHIFT_MIN) && (value <= SHIFT_MAX)) { + _global.conf->shiftX = value; + } + at = strstr(data, "shifty = "); + if ((at != NULL) && !(_global.conf->given & GIVEN_SHIFT) && (sscanf(at, "shifty = %d", &value) == 1) && (value >= SHIFT_MIN) && (value <= SHIFT_MAX)) { + _global.conf->shiftY = value; + } + at = strstr(data, "rotate = "); + if ((at != NULL) && !(_global.conf->given & GIVEN_ROTATE) && (sscanf(at, "rotate = %d", &value) == 1) && ((value % ROTATE_STEP) == 0)) { + _global.conf->rotate = value; + } + free(data); + } + videoSetAudioCalibration(delay); +} + + static void _logicalRect(SDL_FRect *rect) { rect->x = 0.0f; rect->y = 0.0f; @@ -4302,6 +4330,13 @@ static int32_t _luaTraceback(lua_State *L) { // A pointer position in presentation coordinates to the overlay coordinates the game works in: // the rotation comes off first, then the position maps into whatever the shift, the scale factor // and the Sinden border left of the picture. +// The machine file lives beside the data directories, since what it holds belongs to the cabinet +// rather than to any game. +static char *_machineFilePath(void) { + return utilCreateString("%s%s", _global.conf->dataDirBase, MACHINE_FILE); +} + + static void _mapPointer(float px, float py, int32_t *x, int32_t *y) { float vx = 0; float vy = 0; @@ -4954,6 +4989,24 @@ static void _pushConstants(lua_State *L) { lua_setglobal(L, "SINGE_MOUSE_STRIDE"); lua_pushinteger(L, MAX_CONTROLLERS); lua_setglobal(L, "SINGE_MAX_CONTROLLERS"); + lua_pushinteger(L, CONTROLLER_AXIS_COUNT); + lua_setglobal(L, "SINGE_CONTROLLER_AXES"); + lua_pushinteger(L, CONTROLLER_BUTTON_COUNT); + lua_setglobal(L, "SINGE_CONTROLLER_BUTTONS"); + + // What vldpSetScale, vldpSetShift and vldpSetRotate will take, so a service screen clamps to + // the engine's limits rather than to its own copy of them: a shift out of range ends the + // script, which makes a stale copy fatal rather than merely wrong. + lua_pushinteger(L, SCALE_FACTOR_MIN); + lua_setglobal(L, "SINGE_SCALE_MIN"); + lua_pushinteger(L, SCALE_FACTOR_MAX); + lua_setglobal(L, "SINGE_SCALE_MAX"); + lua_pushinteger(L, SHIFT_MIN); + lua_setglobal(L, "SINGE_SHIFT_MIN"); + lua_pushinteger(L, SHIFT_MAX); + lua_setglobal(L, "SINGE_SHIFT_MAX"); + lua_pushinteger(L, ROTATE_STEP); + lua_setglobal(L, "SINGE_ROTATE_STEP"); lua_pushinteger(L, MAX_MICE); lua_setglobal(L, "SINGE_MAX_MICE"); @@ -5114,6 +5167,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "discGetSubtitleLanguage", apiDiscGetSubtitleLanguage); // 3.00 lua_register(L, "discGetSubtitleTracks", apiDiscGetSubtitleTracks); // 3.00 lua_register(L, "discGetFrame", apiDiscGetFrame); // 1.xx + lua_register(L, "discGetFrameCount", apiDiscGetFrameCount); // 3.00 lua_register(L, "discGetHeight", apiDiscGetHeight); // 2.00 lua_register(L, "discGetLanguage", apiDiscGetLanguage); // 2.10 lua_register(L, "discGetState", apiDiscGetState); // 1.xx RDG @@ -5419,12 +5473,14 @@ static void _registerApi(lua_State *L) { lua_register(L, "singeGetAudioLatency", apiSingeGetAudioLatency); // 3.00 lua_register(L, "singeGetDataPath", apiSingeGetDataPath); // 2.00 lua_register(L, "singeGetHeight", apiSingeGetHeight); // 1.xx + lua_register(L, "singeGetSystemInfo", apiSingeGetSystemInfo); // 3.00 lua_register(L, "singeGetPauseFlag", apiSingeGetPauseFlag); // 1.xx RDG lua_register(L, "singeGetScriptPath", apiSingeGetScriptPath); // 1.15 RDG lua_register(L, "singeGetTicks", apiSingeGetTicks); // 3.00 lua_register(L, "singeGetWidth", apiSingeGetWidth); // 1.xx lua_register(L, "singeQuit", apiSingeQuit); // 1.xx RDG lua_register(L, "singeReload", apiSingeReload); // 3.00 + lua_register(L, "singeSaveGeometry", apiSingeSaveGeometry); // 3.00 lua_register(L, "singeScreenshot", apiSingeScreenshot); // 1.xx lua_register(L, "singeSetAudioCalibration", apiSingeSetAudioCalibration); // 3.00 lua_register(L, "singeSetAudioDelay", apiSingeSetAudioDelay); // 3.00 @@ -5554,6 +5610,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "vldpGetPixel", apiVldpGetPixel); // 1.xx lua_register(L, "vldpGetRotate", apiVldpGetRotate); // Hypseus lua_register(L, "vldpGetScale", apiVldpGetScale); // Hypseus + lua_register(L, "vldpGetShift", apiVldpGetShift); // 3.00 lua_register(L, "vldpGetWidth", apiDiscGetWidth); // 1.xx Same as discGetWidth. lua_register(L, "vldpGetYUVPixel", apiVldpGetYUVPixel); // Hypseus lua_register(L, "vldpResetFocus", apiVldpResetFocus); // Hypseus @@ -5562,6 +5619,7 @@ static void _registerApi(lua_State *L) { lua_register(L, "vldpSetMonochrome", apiVldpSetMonochrome); // Hypseus lua_register(L, "vldpSetRotate", apiVldpSetRotate); // Hypseus lua_register(L, "vldpSetScale", apiVldpSetScale); // Hypseus + lua_register(L, "vldpSetShift", apiVldpSetShift); // 3.00 lua_register(L, "vldpSetVerbose", apiVldpSetVerbose); // 1.xx } @@ -5815,12 +5873,18 @@ static bool _sameLoosePath(const char *a, const char *b) { } -static void _saveAudioCalibration(int32_t milliseconds) { - char *path = utilCreateString("%s%s", _global.conf->dataDirBase, AUDIO_CALIBRATION_FILE); +static void _saveMachineSettings(int32_t milliseconds) { + char *path = _machineFilePath(); FILE *out = fopen(path, "w"); + // Everything the service tools can set for this machine goes in one file, and it is written + // whole: the geometry comes from what is live now, which is what the operator just adjusted. if (out) { fprintf(out, "delay = %d\n", milliseconds); + fprintf(out, "scale = %d\n", _global.videoScale); + fprintf(out, "shiftx = %d\n", _global.videoShiftX); + fprintf(out, "shifty = %d\n", _global.videoShiftY); + fprintf(out, "rotate = %d\n", _global.videoRotate); fclose(out); } else { utilSay("Unable to write %s", path); @@ -8328,6 +8392,21 @@ static int32_t apiDiscGetSubtitleTracks(lua_State *L) { } +// count = discGetFrameCount() How many frames the disc holds, which is the last frame plus one. +// With a frame file it is the whole disc rather than the segment playing. +static int32_t apiDiscGetFrameCount(lua_State *L) { + int64_t count = 0; + + _argCheck(L, "discGetFrameCount", 0, 0); + if (_global.videoHandle >= 0) { + count = _global.conf->isFrameFile ? frameFileGetFrameCount(_global.frameFileHandle) : videoGetFrameCount(_global.videoHandle); + } + lua_pushinteger(L, (lua_Integer)count); + + return 1; +} + + // frame = discGetFrame() static int32_t apiDiscGetFrame(lua_State *L) { int64_t frame = 0; @@ -12533,6 +12612,55 @@ static int32_t apiSingeGetAudioLatency(lua_State *L) { } +// info = singeGetSystemInfo() +// What the trace header says about this machine, as a table, so a service screen can put it on +// screen and a photograph of it can answer most of a bug report. The same strings, from the same +// place, rather than a second description that could drift from the first. +static int32_t apiSingeGetSystemInfo(lua_State *L) { + char *os = mainDescribeOs(); + char *cpu = mainDescribeCpu(); + char *audio = mainDescribeAudioDecoders(); + int32_t width = 0; + int32_t height = 0; + + _argCheck(L, "singeGetSystemInfo", 0, 0); + lua_newtable(L); + lua_pushstring(L, VERSION_STRING); lua_setfield(L, -2, "version"); + lua_pushstring(L, (os != NULL) ? os : ""); lua_setfield(L, -2, "os"); + lua_pushstring(L, (cpu != NULL) ? cpu : ""); lua_setfield(L, -2, "cpu"); + lua_pushstring(L, SDL_GetRendererName(_global.renderer)); lua_setfield(L, -2, "renderer"); + lua_pushstring(L, (_global.device != NULL) ? SDL_GetGPUDeviceDriver(_global.device) : "none (3D unavailable)"); lua_setfield(L, -2, "gpu"); + lua_pushstring(L, videoGetDecoderDescription()); lua_setfield(L, -2, "decoder"); + lua_pushstring(L, (audio != NULL) ? audio : ""); lua_setfield(L, -2, "audio"); + lua_pushstring(L, midiSoundfont()); lua_setfield(L, -2, "soundFont"); + lua_pushstring(L, midiIoDescription()); lua_setfield(L, -2, "midi"); + lua_pushstring(L, _global.conf->dataDir); lua_setfield(L, -2, "dataPath"); + lua_pushstring(L, _global.conf->dataDirBase); lua_setfield(L, -2, "dataRoot"); + lua_pushinteger(L, _global.canvasWidth); lua_setfield(L, -2, "canvasWidth"); + lua_pushinteger(L, _global.canvasHeight); lua_setfield(L, -2, "canvasHeight"); + SDL_GetWindowSize(_global.window, &width, &height); + lua_pushinteger(L, width); lua_setfield(L, -2, "windowWidth"); + lua_pushinteger(L, height); lua_setfield(L, -2, "windowHeight"); + free(os); + free(cpu); + free(audio); + + return 1; +} + + +// singeSaveGeometry() Keeps the picture's scale, shift and rotation for this machine, beside the +// audio delay, so an operator sets a monitor up once rather than once per game. The live values +// are what is written, which is what the service menu has just been adjusting. +static int32_t apiSingeSaveGeometry(lua_State *L) { + _argCheck(L, "singeSaveGeometry", 0, 0); + _saveMachineSettings(videoGetAudioCalibration()); + _luaTrace(L, "singeSaveGeometry", "%d %d %d %d", _global.videoScale, _global.videoShiftX, _global.videoShiftY, _global.videoRotate); + + return 0; +} + + // path = singeGetDataPath() static int32_t apiSingeGetDataPath(lua_State *L) { _luaTrace(L, "singeGetDataPath", "%s", _global.conf->dataDir); @@ -12632,7 +12760,7 @@ static int32_t apiSingeSetAudioCalibration(lua_State *L) { _luaDie(L, "singeSetAudioCalibration", "Audio calibration must be between %d and %d milliseconds: %d", -VIDEO_AUDIO_DELAY_MAX, VIDEO_AUDIO_DELAY_MAX, value); } videoSetAudioCalibration(value); - _saveAudioCalibration(value); + _saveMachineSettings(value); _luaTrace(L, "singeSetAudioCalibration", "%d", value); return 0; @@ -14878,6 +15006,39 @@ static int32_t apiVldpSetRotate(lua_State *L) { // applied = vldpSetScale(percent) Hypseus extension. Shrinks the picture about its centre, 25 to // 100 per cent; anything else answers false, as does a second change within 15 milliseconds, which // is the rate Hypseus limits its own held-key zoom to. +// x, y = vldpGetShift() Where the picture sits inside the room the scale factor leaves, as +// --shiftx and --shifty set it, -100 to 100. +static int32_t apiVldpGetShift(lua_State *L) { + _argCheck(L, "vldpGetShift", 0, 0); + lua_pushinteger(L, _global.videoShiftX); + lua_pushinteger(L, _global.videoShiftY); + + return 2; +} + + +// vldpSetShift(x, y) Moves the picture inside that room, live. vldpGetScale and vldpSetScale +// decide how much room there is. +static int32_t apiVldpSetShift(lua_State *L) { + int32_t x = 0; + int32_t y = 0; + + _argCheck(L, "vldpSetShift", 2, 2); + x = _argInteger(L, "vldpSetShift", 1); + y = _argInteger(L, "vldpSetShift", 2); + if ((x < SHIFT_MIN) || (x > SHIFT_MAX) || (y < SHIFT_MIN) || (y > SHIFT_MAX)) { + _luaDie(L, "vldpSetShift", "A shift is %d to %d per cent: %d, %d", SHIFT_MIN, SHIFT_MAX, x, y); + } + _global.videoShiftX = x; + _global.videoShiftY = y; + _computeVideoRect(); + _global.refreshDisplay = true; + _luaTrace(L, "vldpSetShift", "%d %d", x, y); + + return 0; +} + + static int32_t apiVldpSetScale(lua_State *L) { int32_t percent = 0; uint64_t now = utilTicks(); @@ -15185,7 +15346,8 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // packed game carries none of it inside the .game. persistOpen(_global.conf->dataDir); videoSetAudioDelay(_global.conf->audioDelayMs); - videoSetAudioCalibration(_loadAudioCalibration()); + // What the service tools left this machine set to, over whatever the command line asked for. + _loadMachineSettings(); utilTrace("Audio delay: device queue %d ms, calibration %d ms, game %d ms", videoGetAudioLatency(), videoGetAudioCalibration(), videoGetAudioDelay()); _loadControlMappings(); diff --git a/src/singe.h b/src/singe.h index a67780170..022c03cb6 100644 --- a/src/singe.h +++ b/src/singe.h @@ -70,7 +70,10 @@ typedef enum GivenE { GIVEN_AUDIO_TRACK = 1 << 5, GIVEN_AUDIO_DELAY = 1 << 6, GIVEN_CANVAS = 1 << 7, - GIVEN_AUDIO_SUFFIX = 1 << 8 + GIVEN_AUDIO_SUFFIX = 1 << 8, + GIVEN_SCALE = 1 << 9, + GIVEN_SHIFT = 1 << 10, // -X or -Y + GIVEN_ROTATE = 1 << 11 } GivenE; typedef struct ConfigS {