Skip to content

Latest commit

 

History

History
875 lines (629 loc) · 37.1 KB

API_REFERENCE.md

File metadata and controls

875 lines (629 loc) · 37.1 KB

PZFB API Reference

Setup

require "PZFB/PZFBApi"

All functions are on the global PZFB table. All functions are safe to call even if class files aren't deployed — they return nil/false gracefully.

Versions. The Lua API (this file) ships with the mod; the Java side ships as class files the user installs once. They can differ: a user on PZFB 1.8.0 Lua may still have 1.7.0 class files until they re-run the installer. Everything marked (1.8.0 class files) below is feature-detected through PZFB.hasSessions() and degrades to the 1.7 behaviour otherwise. PZFB.AVAILABLE and PZFB.VERSION are set when PZFBInit.lua loads (1.8.0; previously only at OnGameStart), so they are safe to read from file-level code and main-menu handlers.


Core API

PZFB.isAvailable()

Check if PZFB class files are deployed and functional.

  • Returns: boolean
  • When to use: Before creating framebuffers. Valid from load time (1.8.0); OnGameStart still works.
Events.OnGameStart.Add(function()
    if not PZFB.isAvailable() then
        print("PZFB not installed")
        return
    end
    -- safe to create framebuffers
end)

PZFB.getVersion()

Get the PZFB version string (from mod.info; the class files' own version is what Color.fbPing() reports).

  • Returns: string|nil — e.g. "1.8.0", or nil if unavailable.

Sessions (1.8.0 class files)

PZFB has one shared slot for the game process and the video stream — one ring buffer, one set of status getters. Before 1.8 any mod could call gameStop()/streamStop() and kill whatever another mod was running, or read another mod's frames into a texture of the wrong size. 1.8 tags every start with a session id:

  • gameStart, gameStartArgs and streamStart return the id (a number > 0; one counter shared by games and streams). The id is returned even if the launch fails — the failed session then owns the slot in status 4 (game) / 5 (stream) with the error set, until it is stopped or replaced.
  • Every start first ends whatever session currently owns the slot (graceful stop, see gameStop).
  • Pass the id to gameStop(id), streamStop(id) and streamFrame(fb, idx, id): they act only if that id still owns the slot.
  • Status getters stay global (gameStatus(), streamStatus(), streamWidth()…). Check PZFB.ownsSession(id) before trusting them.
  • Background threads inside PZFB are session-scoped too: a stale reader from a replaced session can no longer write into the new session's buffer or flip its status.

PZFB.hasSessions()

  • Returns: boolean — true iff the deployed class files report Color.fbApiLevel() >= 2 (pcall-guarded, cached once available). False with 1.7.x class files.

PZFB.sessionId()

  • Returns: number|nil — id currently owning the slot (0 = none), or nil when sessions are unsupported.

PZFB.ownsSession(id)

  • Parameters: id (number|nil) — the id your start call returned.
  • Returns: boolean — id == PZFB.sessionId(). Returns true when id is nil or sessions are unsupported, so the same code runs unchanged on 1.7 class files.

Recommended pattern

-- start
self.session = PZFB.gameStartArgs(bin, 256, 240, argv)   -- number on 1.8, nil on 1.7

-- every frame
if not PZFB.ownsSession(self.session) then
    -- another mod took the slot: free your texture, show a message, forget the id
    self.session = nil
    return
end
if PZFB.gameStatus() == 2 then
    if PZFB.streamFrame(self.fb, idx, self.session) then self.current = idx end
    -- false means "not uploaded this frame" (not current, or >3 uploads pending): retry
end

-- close
PZFB.gameStop(self.session)   -- no-op if the slot is someone else's now
self.session = nil

Legacy callers that ignore the return values keep working: gameStop() / streamStop() without an id stop the current session only if it is of their own kind (1.8.0 change — a gameStop() no longer tears down a video stream and vice versa).

PZFB.create(width, height)

Create a framebuffer with NEAREST filtering (pixel-perfect, ideal for emulators and pixel art).

  • Parameters:
    • width (number) — pixel width
    • height (number) — pixel height
  • Returns: table|nil — framebuffer handle, or nil if unavailable
  • Note: GL texture allocation is async. Poll PZFB.isReady(fb) before drawing or filling.
local fb = PZFB.create(160, 144)  -- Game Boy resolution

PZFB.createLinear(width, height)

Create a framebuffer with LINEAR filtering (smoother scaling, better for video playback).

  • Parameters: same as create()
  • Returns: same as create()

PZFB.isReady(fb)

Check if a framebuffer's GL texture has been allocated by the render thread.

  • Parameters:
    • fb (table) — framebuffer handle from create()
  • Returns: boolean
  • Note: Typically becomes ready within one frame (~16ms at 60fps). Poll in render() or OnTick.

PZFB.fill(fb, r, g, b, a)

Fill the entire framebuffer with a solid RGBA color.

  • Parameters:
    • fb (table) — framebuffer handle
    • r (number) — red, 0-255
    • g (number) — green, 0-255
    • b (number) — blue, 0-255
    • a (number) — alpha, 0-255
  • Note: The fill is queued to the render thread. Visible on the next frame.
  • 1.8.0: pixels go into native memory that is freed on the render thread right after the GL upload. At most 3 uploads per framebuffer may be pending; a fill issued while 3 are queued is dropped (the Java method is void — there is no return value to check). In practice one fill per frame never hits the cap.
PZFB.fill(fb, 255, 0, 0, 255)    -- solid red
PZFB.fill(fb, 0, 0, 0, 0)        -- fully transparent

PZFB.loadRaw(fb, path)

Load raw RGBA pixel data from a file into the framebuffer.

  • Parameters:
    • fb (table) — framebuffer handle
    • path (string) — absolute file path to raw RGBA data
  • Returns: boolean — true if loaded successfully, false on error. 1.8.0: also false when the file size ≠ width * height * 4 (checked before reading) or when 3 uploads are already pending for this framebuffer (retry next frame).
  • File format: Raw RGBA bytes, 4 bytes per pixel (R, G, B, A), no header, no compression.
  • File size: Must be exactly width * height * 4 bytes.
-- 256x240 NES frame = 245,760 bytes
local ok = PZFB.loadRaw(fb, "/home/user/Zomboid/Lua/emulator/frame.raw")

Common resolutions and file sizes:

Resolution Use case File size
64x64 Test/icon 16,384 bytes
160x144 Game Boy 92,160 bytes
256x240 NES 245,760 bytes
320x240 General 307,200 bytes
640x480 VGA 1,228,800 bytes

PZFB.loadRawFrame(fb, path, frameIndex)

Load a single frame from a concatenated raw RGBA file. The file contains multiple frames of width * height * 4 bytes each, back to back (e.g., output from ffmpeg -f rawvideo).

  • Parameters:
    • fb (table) — framebuffer handle
    • path (string) — absolute file path to concatenated raw RGBA data
    • frameIndex (number) — zero-based frame index
  • Returns: boolean — true if loaded, false if out of range, error, or (1.8.0) 3 uploads already pending
  • Note: Reads only the requested frame (FileChannel.read at an offset straight into native memory) — does not read the entire file.
-- Play video frames sequentially
local frameNum = 0
function MyPanel:onTick()
    if PZFB.loadRawFrame(fb, "/path/to/video.raw", frameNum) then
        frameNum = frameNum + 1
    else
        frameNum = 0  -- loop or stop
    end
end

PZFB.fileSize(path)

Get the size of a file in bytes. Useful for calculating total frame count.

  • Parameters:
    • path (string) — absolute file path
  • Returns: number — file size in bytes, or -1 if file doesn't exist
local totalFrames = PZFB.fileSize("/path/to/video.raw") / (width * height * 4)

PZFB.getTexture(fb)

Get the underlying PZ Texture object for drawing.

  • Parameters:
    • fb (table) — framebuffer handle
  • Returns: Texture|nil
  • Usage: Pass to drawTextureScaled() in a UI panel's render() method.
function MyPanel:render()
    ISPanel.render(self)
    if fb and PZFB.isReady(fb) then
        local tex = PZFB.getTexture(fb)
        self:drawTextureScaled(tex, 10, 10, 492, 492, 1, 1, 1, 1)
    end
end

PZFB.destroy(fb)

Destroy a framebuffer and free its GL resources.

  • Parameters:
    • fb (table) — framebuffer handle
  • Note: Always call this when you're done with a framebuffer to prevent resource leaks. Safe to call before isReady() is true (1.8.0: the delete is queued behind the texture's own creation; earlier versions leaked the GL texture in that case).

Audio Playback (Direct FMOD)

Bypasses PZ's sound bank system entirely. Load and play any audio file from any absolute path — no script registration, no media/sound/ directory required. Supports true pause/resume and seeking.

Note: One audio track at a time. Loading a new file releases the previous one.

PZFB.audioLoad(path)

Load an audio file for playback.

  • Parameters:
    • path (string) — absolute file path (OGG, WAV, MP3, FLAC — anything FMOD supports)
  • Returns: boolean — true if loaded successfully
  • Note: Stops and releases any previously loaded audio.
PZFB.audioLoad("/home/user/Zomboid/PZVP/converted/myvideo/audio.ogg")

PZFB.audioPlay()

Start playback from the beginning.

  • Returns: boolean — true if playback started

PZFB.audioPause()

Pause playback. Position is preserved — call audioResume() to continue.

PZFB.audioResume()

Resume playback from the paused position.

PZFB.audioStop()

Stop playback and release the audio resource.

PZFB.audioSetVolume(volume)

Set playback volume.

  • Parameters:
    • volume (number) — 0.0 (silent) to 1.0 (full volume)

PZFB.audioSeek(positionMs)

Seek to a position in milliseconds.

  • Parameters:
    • positionMs (number) — target position in milliseconds

PZFB.audioGetPosition()

Get the current playback position.

  • Returns: number — current position in milliseconds

PZFB.audioGetLength()

Get the total audio length.

  • Returns: number — total length in milliseconds

PZFB.audioIsPlaying()

Check if audio is currently playing.

  • Returns: boolean

Audio Example

-- Load and play audio synced with video
PZFB.audioLoad("/path/to/audio.ogg")
PZFB.audioPlay()
PZFB.audioSetVolume(0.8)

-- Pause/resume
PZFB.audioPause()
PZFB.audioResume()

-- Seek to 5 seconds
PZFB.audioSeek(5000)

-- Check status
local pos = PZFB.audioGetPosition()    -- e.g. 5000
local len = PZFB.audioGetLength()      -- e.g. 30000
local playing = PZFB.audioIsPlaying()  -- true/false

-- Clean up
PZFB.audioStop()

Video Conversion (ffmpeg)

Convert video files to raw RGBA frames + OGG audio using ffmpeg, running in a background thread. Non-blocking — poll convertStatus() to check progress.

Requires: ffmpeg and ffprobe on the system PATH.

PZFB.convertStart(inputPath, outputDir, width, height)

Start an asynchronous video conversion.

  • Parameters:
    • inputPath (string) — absolute path to source video (MP4, AVI, MKV, etc.)
    • outputDir (string) — absolute path to output directory (created if needed)
    • width (number) — target width in pixels
    • height (number) — target height in pixels
  • Returns: boolean — true if conversion started, false if already running or file not found
  • Output files:
    • video.raw — concatenated raw RGBA frames (use with loadRawFrame())
    • audio.ogg — extracted audio track (if source has audio)
    • meta.txt — metadata (width, height, frames, fps, file paths)

PZFB.convertStatus()

Poll the conversion status.

  • Returns: number — 0=idle, 1=running, 2=done, 3=error

PZFB.convertError()

Get the error message from a failed conversion.

  • Returns: string — error message, or empty string

PZFB.convertReset()

Reset the conversion status back to idle. Only works when not currently converting.

PZFB.ffmpegAvailable()

Check if ffmpeg is available on the system PATH.

  • Returns: boolean

Conversion Example

-- Check ffmpeg first
if not PZFB.ffmpegAvailable() then
    print("ffmpeg not found!")
    return
end

-- Start conversion
PZFB.convertStart("/path/to/video.mp4", "/output/dir", 256, 192)

-- Poll in OnTick or render
local status = PZFB.convertStatus()
if status == 1 then
    -- still converting...
elseif status == 2 then
    -- done! Load the video
    PZFB.convertReset()
elseif status == 3 then
    print("Error: " .. PZFB.convertError())
    PZFB.convertReset()
end

Utilities

PZFB.listDir(dirPath)

List files in a directory.

  • Parameters:
    • dirPath (string) — absolute path to directory
  • Returns: string — newline-separated filenames (files only, not subdirectories), or empty string if directory doesn't exist
local files = PZFB.listDir("/home/user/Zomboid/PZVP")
-- "video1.mp4\nvideo2.avi\n..."

PZFB.readTextFile(path)

Read a text file and return its contents as a string. Useful for reading metadata files from any location (not restricted to ~/Zomboid/Lua/ like getFileReader).

  • Parameters:
    • path (string) — absolute file path
  • Returns: string — file contents (UTF-8), or empty string if file doesn't exist
local meta = PZFB.readTextFile("/path/to/meta.txt")

PZFB.copyFile(src, dst)

Copy a file from any path to any path. Creates parent directories at the destination if they don't exist. Overwrites the destination if it already exists.

  • Parameters:
    • src (string) — absolute source file path
    • dst (string) — absolute destination file path
  • Returns: boolean — true if copied successfully, false on error or missing source
  • Use case: Deploy binaries from Workshop mods (Steam blocks .exe uploads — bundle as .bin/.dat, copy to a working location at runtime, then launch via PZFB.gameStart()).
local modDir = "/path/to/workshop/mod"
local gameDir = Core.getMyDocumentFolder() .. "/PZFB/games"
PZFB.copyFile(modDir .. "/mygame.dat", gameDir .. "/mygame")
PZFB.gameStart(gameDir .. "/mygame", 320, 200)

Streaming Video / Audio (ffmpeg)

Streams a video file through ffmpeg into an in-memory ring buffer of raw RGBA frames (no temp video file) and extracts the audio track to a temporary WAV for the FMOD audio API. This is what PZVP uses. Requires ffmpeg + ffprobe on PATH. Shares the one game/stream slot (see Sessions).

PZFB.streamStart(path, scale, bufferFrames)

  • Parameters:
    • path (string) — absolute path to the video file
    • scale (number) — 0.0–1.0 fraction of the source resolution (default 1.0)
    • bufferFrames (number) — ring-buffer capacity in frames (default 120)
  • Returns: number|nil — session id (1.8.0 class files), nil otherwise. Returned even if probing fails (status 5 + streamError()).
  • Behaviour: ends whatever session owns the slot, probes with ffprobe (status 1), starts the frame writer (status 2 once dimensions are known, 3 when the buffer has enough frames, 4 when ffmpeg finished) and the WAV extractor. Create your texture with createLinear(streamWidth(), streamHeight()) once streamStatus() >= 2.

PZFB.streamFrame(fb, frameIndex, id)

Upload ring-buffer frame frameIndex (absolute index since the start of the stream) into fb.

  • Parameters:
    • fb (table) — framebuffer handle; its size must equal the stream's frame size (1.8.0 refuses mismatches)
    • frameIndex (number) — absolute frame index; frames more than 10 behind the newest are discarded
    • id (number|nil) — (1.8.0) session id; when given and supported, nothing is uploaded unless id still owns the slot
  • Returns: boolean — true if an upload was queued. False means "not this frame": frame not in the buffer yet, wrong session, size mismatch, or 3 uploads already pending. Retry next render; detect "ended" via streamStatus()/ownsSession(), never via a false return.

PZFB.streamSeek(timeSec)

Restart ffmpeg at timeSec (the previous writer thread is retired, not joined). 1.8.0: no-op unless the current session is a stream — only call it when you own the slot.

PZFB.streamStop(id)

  • Parameters: id (number|nil) — (1.8.0) with an id and 1.8 class files: stops only if id owns the slot (returns true/false). Without an id: legacy stop of the current session if it is a stream (1.8.0: no longer ends a game). Also kills the ffmpeg helpers (off-thread) and deletes the temp WAV.

Status getters (all global — check ownsSession(id) first)

Function Returns
PZFB.streamStatus() 0 idle · 1 probing · 2 buffering (dimensions known) · 3 ready · 4 done · 5 error
PZFB.streamError() error text when status is 5
PZFB.streamWidth() / streamHeight() scaled frame size (also set by a running game session)
PZFB.streamFps() / streamDuration() / streamTotalFrames() from ffprobe
PZFB.streamBufferStart() / streamBufferCount() first frame index held and how many — start + count - 1 is the newest frame (what game consumers show)
PZFB.streamAudioPath() / streamAudioReady() / streamAudioDone() temp WAV path; ≥ ~2 s extracted; extraction finished (reload the audio then, FMOD caches the length at load)

Game Process (Interactive Applications)

Launch an external game binary with bidirectional I/O. The game writes raw RGBA frames to stdout (read into the stream ring buffer) and reads key events from stdin. Frames are uploaded to a texture via PZFB.streamFrame().

Note: The game API shares the stream ring buffer. It cannot run simultaneously with PZFB.streamStart() — starting one stops the other (gracefully, see gameStop). Use the session id (1.8.0) so you can tell when that happened to you.

PZFB.gameStart(binaryPath, width, height, extraArgs)

Launch a game process. Legacy string-based form.

  • Parameters:
    • binaryPath (string) — absolute path to the game binary
    • width (number) — frame width in pixels
    • height (number) — frame height in pixels
    • extraArgs (string|nil) — space-separated additional command line arguments
  • Returns: number|nil — session id (1.8.0 class files), nil on 1.7.x class files or when PZFB is unavailable. Returned even if the launch fails (status 4 + gameError()).
  • Note: Automatically sets the binary's execute permission (Workshop may strip +x). Works inside Steam's pressure-vessel container (uses the host linker for absolute paths). 1.8.0: the host-linker path is only used by a Linux JVM — Windows PZ under Proton (which inherits the container variables) used to fail with CreateProcess error=193.

Important: extraArgs is a single whitespace-separated string. Arguments containing spaces MUST be wrapped in double quotes: "path with spaces". The splitter handles "..." only — no nested quotes, no backslash escapes, no single quotes. For any argument derived from user input (ROM paths, Documents folder, usernames with spaces or Unicode), prefer PZFB.gameStartArgs() (1.7.0+) which bypasses parsing entirely.

PZFB.gameStartArgs(binaryPath, width, height, argv) (1.7.0+)

Launch a game process with an argv array. Preferred form for any argument that may contain spaces, quotes, backslashes, or non-ASCII characters.

  • Parameters:
    • binaryPath (string) — absolute path to the game binary
    • width (number) — frame width in pixels
    • height (number) — frame height in pixels
    • argv (table|nil) — Lua array (1-based integer keys) of string arguments; each element is passed to the child process verbatim. nil and empty table both mean "no extra args".
  • Returns: number|nil — session id (1.8.0 class files), nil otherwise.
  • Behaviour: Identical to PZFB.gameStart() once the child process is running — same stdout/stdin wiring, same status states, same gameSendInput/gameStatus/gameStop lifecycle.
  • Why use this: Windows usernames with spaces (C:\Users\Adam Marzello\...), OneDrive-redirected Documents folders, apostrophes in paths (O'Brien), Cyrillic/CJK/accented-Latin usernames all pass through unchanged. No quoting, no escaping.
-- Compose argv from any user-supplied strings without worrying about escaping:
PZFB.gameStartArgs(binaryPath, 256, 240, {
    corePath,                -- e.g. "C:/Users/Adam Marzello/Documents/Zomboid/PZEMU/fceumm_libretro.dll"
    romPath,                 -- e.g. "C:/Users/Adam Marzello/Documents/Zomboid/PZEMU/roms/nes/Super Mario.nes"
    tostring(width),
    tostring(height),
    saveDir,
    saveDir,
})

PZFB.gameSendInput(keycode, pressed)

Send a key event to the game process via stdin.

  • Parameters:
    • keycode (number) — key code (0-255)
    • pressed (number) — 1 for pressed, 0 for released
  • Wire protocol: Bytes are sent in order [pressed, keycode] — the pressed byte is written first, then the keycode byte. This is the opposite order from the Lua parameter list. Game binaries reading stdin must read pressed first, then keycode.

PZFB.gameIsRunning()

  • Returns: boolean — true if a game session is active. 1.8.0: false immediately after a stop, even though the child process may live for up to ~1.5 s more while the reaper gives it time to exit.

PZFB.gameStatus()

  • Returns: number — 0=idle, 1=starting, 2=running, 3=exited, 4=error. Global — describes whatever session owns the slot; check PZFB.ownsSession(id) first.

PZFB.gameError()

  • Returns: string — error message, or empty string if no error. When a process exits before producing a frame, the last lines of its stderr are included.

PZFB.gameStop(id)

Stop the game process and free all resources. Safe to call multiple times.

  • Parameters:
    • id (number|nil) — (1.8.0) session id from gameStart/gameStartArgs. With an id (and 1.8 class files) the stop only happens if that id still owns the slot; returns true/false. Without an id: legacy stop of the current session if it is a game (1.8.0: a gameStop() no longer ends a video stream). On 1.7.x class files an id is ignored (legacy stop).
  • Graceful stop (1.8.0): the session ends immediately on the caller (status 0, buffer detached — this call never blocks). A background reaper then closes the child's stdin (EOF) → closes PZFB's read end of its stdout (a child blocked writing frames gets EPIPE) → on Linux/macOS sends SIGTERM (Process.destroy(); skipped on Windows, where it would be TerminateProcess) → waits up to 1.5 s → force-kills. Children should treat stdin EOF and a failed frame write as "save and exit" — that is how emulators keep their saves. Before 1.8.0 the child was killed outright and the caller blocked up to 2 s.

Auto-cleanup (1.7.0+, extended 1.8.0): the same stop sequence runs from a JVM shutdown hook when PZ exits normally, so consumers don't need to call it in every exit path; 1.8.0 also kills stream ffmpeg helpers there and waits (bounded, ≤ 4.5 s) for the child. Caveat: the hook does not run on SIGKILL, power loss, or Windows Task Manager "End task" — such orphans survive until the next PZ launch.

Multiplayer considerations

The Game Process API is client-local. Each player's PZ spawns its own child process on its own machine, and frames are never transported over PZ's network protocol. A consumer showing the same in-world TV to two players will render independent frame streams — if both walk up to the same NES and press "Play", each sees the frames produced by their own bridge process, not the other player's. True frame-sharing across MP would require transporting roughly 17 MB/s of RGBA per concurrent stream plus an input-forwarding consensus layer; that is out of scope for PZFB at this time and deferred to a future major version.

Game Example

-- Launch a game that outputs 256x240 RGBA frames to stdout
local session = PZFB.gameStartArgs("/path/to/mygame", 256, 240, {})  -- id on 1.8, nil on 1.7

-- Create framebuffer to display frames (must match the frame size: 1.8 refuses mismatched uploads)
local fb = PZFB.create(256, 240)
local currentFrame = 0

-- In render():
if not PZFB.ownsSession(session) then
    -- another PZFB mod started something; our session is over
elseif PZFB.gameStatus() >= 2 and fb and PZFB.isReady(fb) then
    -- show the newest frame the ring buffer has
    local latest = PZFB.streamBufferStart() + PZFB.streamBufferCount() - 1
    if latest ~= currentFrame and PZFB.streamFrame(fb, latest, session) then
        currentFrame = latest
    end
    self:drawTextureScaled(PZFB.getTexture(fb), 0, 0, 512, 480, 1, 1, 1, 1)
end

-- Send input (from PZFBInputPanel callback):
function panel:onPZFBKeyDown(key)
    PZFB.gameSendInput(key, 1)  -- wire: [0x01, key]
end
function panel:onPZFBKeyUp(key)
    PZFB.gameSendInput(key, 0)  -- wire: [0x00, key]
end

-- Cleanup:
PZFB.gameStop(session)   -- scoped: cannot stop another mod's game
PZFB.destroy(fb)

Input Capture (v2.0)

require "PZFB/PZFBInput"

B42 compatibility: PZFB 1.7.2+ supports both PZ 42.16.3 (and earlier B42 builds) and PZ 42.17+ (B42 build 22869276 and later), verified through 42.21 (1.8.0). The April 22 PZ patch refactored the gamepad-button API — isJoypadPressed was renamed to JoypadButton.isButtonDown, and the per-controller getJoypadAButton/BButton/XButton/YButton/LBumper/RBumper/BackButton/StartButton/LeftStickButton/RightStickButton globals were removed in favour of JoypadButton.fromIndex(). PZFB 1.7.2 feature-detects at load time and dispatches to whichever API the running PZ exposes. Earlier PZFB releases (1.7.0 and 1.7.1) were single-version: 1.7.0 only worked pre-patch, 1.7.1 only worked post-patch.

PZFBInputPanel

ISPanelJoypad subclass providing comprehensive keyboard, mouse, and gamepad input capture. Supports four capture modes, a capture toggle key, action mapping, multi-controller input slots, and automatic cleanup on close/crash.

Capture Modes

PZFBInput.MODE_EXCLUSIVE  -- Consume ALL keys, block game entirely
PZFBInput.MODE_SELECTIVE  -- Consume only registered keys
PZFBInput.MODE_PASSIVE    -- Read everything, consume nothing
PZFBInput.MODE_FOCUS      -- Exclusive when mouse over panel, passive otherwise

Constructor

local panel = PZFBInputPanel:new(x, y, width, height, {
    mode              = PZFBInput.MODE_EXCLUSIVE,  -- capture mode
    captureToggleKey  = nil,          -- nil or Keyboard.KEY_* to toggle capture
    escapeCloses      = true,         -- ESC closes panel (default true)
    escapeReleasesCapture = true,     -- ESC releases toggle capture (safety)
    playerNum         = 0,            -- PZ player number (splitscreen)
    forceCursorVisible = true,        -- show cursor over panel
    autoGrab          = false,        -- grab input on createChildren
})
panel:initialise()
panel:addToUIManager()

Capture Control

  • panel:grabInput() — Start capturing input. Auto-detects connected controllers, suppresses PZ's gamepad and keyboard binding processing. (1.8.0: also re-registers the panel's safety listeners if a previous close()/cleanup() removed them.)
  • panel:releaseInput() — Stop capturing, restore all state (bindings, gamepad, joypad bind).
  • panel:isCapturing() — Returns boolean.
  • panel:setMode(mode) — Change capture mode at runtime.
  • panel:getMode() — Returns current mode.
  • panel:cleanup() — (1.8.0) Public, idempotent teardown. Releases capture (restores game bindings, PZ controller polling, joypad focus and the cursor), destroys the invisible key proxy, and removes every event listener the panel registered (OnPlayerDeath, OnMainMenuEnter, OnGamepadConnect, OnGamepadDisconnect). Call it when the panel is a child of a window that is being removed: removeFromUIManager() on the parent does not notify children, so without it the listeners and the key proxy keep the panel alive and capturing. close() and removeFromUIManager() on the panel itself still clean up as before; calling cleanup() afterwards (or twice) is harmless. Window teardown idiom that also works on 1.7.x:
    if self.panel.cleanup then self.panel:cleanup() else self.panel:releaseInput() end
    self:removeFromUIManager()

Keyboard Callbacks

function panel:onPZFBKeyDown(key) end    -- first frame of key press
function panel:onPZFBKeyRepeat(key) end  -- every frame while key is held
function panel:onPZFBKeyUp(key) end      -- key release

Mouse Callbacks

function panel:onPZFBMouseDown(x, y, btn) end    -- btn: 0=left, 1=right, 2+=extra
function panel:onPZFBMouseUp(x, y, btn) end
function panel:onPZFBMouseMove(x, y, dx, dy) end  -- position (panel-relative) + delta
function panel:onPZFBMouseWheel(delta) end

Gamepad Callbacks

function panel:onPZFBGamepadDown(slot, button) end   -- Joypad.AButton, DPadUp, etc.
function panel:onPZFBGamepadUp(slot, button) end
function panel:onPZFBGamepadAxis(slot, axisName, value) end  -- "leftX","leftY","rightX","rightY"
function panel:onPZFBGamepadTrigger(slot, side, pressed) end -- "left"/"right"
function panel:onPZFBCaptureToggle(active) end   -- toggle key state changed
function panel:onPZFBSlotAssigned(slot, controllerId) end  -- controller auto-assigned

onPZFBGamepadAxis firing rule (changed in 1.8.0): fires when the axis has moved more than 0.05 away from the last value reported to this callback (previously: from the previous poll, which let a stick drifting slowly back to center never fire and left consumers holding a stale deflection — the "D-pad stuck" bug in emulators). A return into the center band (|value| ≤ 0.05) is always reported once, with the raw value (not snapped to 0). getGamepadAxis() still returns the raw per-frame value.

PlayStation position remapping: On PS controllers (DualSense, DualShock), face button constants are automatically swapped (A↔B, X↔Y) so they match physical position rather than PZ's label-based mapping. Consumers always receive position-correct button constants regardless of controller type — no per-mod remapping needed.

Gamepad suppression: When capturing (EXCLUSIVE, SELECTIVE, FOCUS+mouse over, or toggle active), PZ's own gamepad processing is fully blocked — the game character will not respond to the captured controller. Released automatically when capture ends.

Keyboard Polling

  • panel:isKeyDown(key) — Is key currently held?
  • panel:isModifierDown(name) — "shift", "ctrl", or "alt" (checks both L+R variants).

Mouse Polling

  • panel:getMousePos() — Returns x, y relative to panel.
  • panel:isMouseButtonDown(btn) — 0=left, 1=right, 2=middle.

Gamepad Polling

  • panel:getGamepadAxis(slot, name) — Returns -1.0 to 1.0. Names: "leftX", "leftY", "rightX", "rightY".
  • panel:isGamepadDown(slot, button) — Joypad button constant.
  • panel:isGamepadTriggerDown(slot, side) — "left" or "right".

Selective Capture (MODE_SELECTIVE)

Register which keys to consume. Unregistered keys pass through to the game.

panel:captureKey(Keyboard.KEY_SPACE)          -- single key
panel:captureKeys({Keyboard.KEY_LEFT, Keyboard.KEY_RIGHT})  -- batch
panel:captureBinding("Forward")               -- by game action name (follows rebinds)
panel:releaseKey(Keyboard.KEY_SPACE)
panel:releaseBinding("Forward")
panel:releaseAllCaptures()

Action Mapping

Map named actions to physical inputs. Multiple bindings per action supported — one binding per mapAction call. The binding table is read as an if/elseif chain (key, else gamepad, else axis, else keyNeg+keyPos), so { key = ..., gamepad = ... } in a single call silently keeps only the key. Call it once per binding:

-- Define
panel:mapAction("jump", { key = Keyboard.KEY_SPACE })
panel:mapAction("jump", { gamepad = Joypad.AButton })
panel:mapAction("moveX", { axis = "leftX" })            -- optional slot = N (default 2)
panel:mapAction("moveX", { keyNeg = Keyboard.KEY_A, keyPos = Keyboard.KEY_D })
panel:unmapAction("jump")

-- Query
panel:isActionDown("jump")       -- true if ANY binding is active
panel:getActionValue("moveX")    -- -1.0 to 1.0 (analog-aware)

Input Slots (Multi-Controller)

Controllers are auto-detected when grabInput() is called. Connected controllers are automatically assigned to slots 2+ with no configuration required. Hot-plug is supported — controllers connected or disconnected mid-session are handled automatically.

-- Auto-detection (default, zero config):
panel:grabInput()                           -- scans and assigns all connected controllers

-- Discovery API:
local controllers = panel:getConnectedControllers()
-- Returns: { {id=0, name="Xbox Controller"}, {id=1, name="PS5 Controller"}, ... }

-- Manual override (optional, for advanced use):
panel:setSlotDevice(1, "keyboard")          -- slot 1 = keyboard + mouse (default)
panel:setSlotDevice(2, "controller", 0)     -- slot 2 = controller #0
panel:setSlotAutoAssign(2, true)            -- auto-assign next controller press

Config Persistence

Save/load action mappings and settings to ~/Zomboid/Lua/.

panel:saveInputConfig("mymod")    -- writes PZFB_input_mymod.cfg
panel:loadInputConfig("mymod")    -- reads and applies

Safety

Input capture is automatically released when:

  • Panel is closed (close())
  • Panel is hidden (setVisible(false))
  • Panel is removed (removeFromUIManager())
  • Player dies
  • Game returns to main menu

_safeRelease() is idempotent. The one case that needs a manual call: a panel that is a child of a window you remove with removeFromUIManager() — PZ does not notify children, so call panel:cleanup() (1.8.0) from the window's close(). Note that B42's ISCollapsableWindow:close() only hides the window; override it if you want a real teardown.

Test window (PZFBTest.lua) (1.8.0)

HOME (create a 64×64 test framebuffer in a resizable window) / END (destroy) are only active when Project Zomboid runs in debug mode (-debug launch option; vanilla getDebug()). PZFB.TEST_DISABLED = true (set by PZVP, which uses END) still turns them off in debug mode. Before 1.8.0 every PZFB user without PZVP had these keys live in game.

Full Example — Emulator

require "PZFB/PZFBApi"
require "PZFB/PZFBInput"

local EmuScreen = PZFBInputPanel:derive("EmuScreen")

function EmuScreen:new(x, y)
    local o = PZFBInputPanel.new(self, x, y, 512, 512, {
        mode = PZFBInput.MODE_EXCLUSIVE,
        captureToggleKey = Keyboard.KEY_SCROLL,
        escapeCloses = false,
    })
    o.fb = nil
    return o
end

function EmuScreen:createChildren()
    PZFBInputPanel.createChildren(self)
    if PZFB.isAvailable() then
        self.fb = PZFB.create(256, 240)
    end
    self:grabInput()
    -- Map emulator controls: one binding per mapAction call
    local map = {
        up    = { Keyboard.KEY_W,      Joypad.DPadUp    },
        down  = { Keyboard.KEY_S,      Joypad.DPadDown  },
        left  = { Keyboard.KEY_A,      Joypad.DPadLeft  },
        right = { Keyboard.KEY_D,      Joypad.DPadRight },
        a     = { Keyboard.KEY_SPACE,  Joypad.AButton   },
        b     = { Keyboard.KEY_LSHIFT, Joypad.BButton   },
    }
    for name, b in pairs(map) do
        self:mapAction(name, { key = b[1] })
        self:mapAction(name, { gamepad = b[2] })
    end
end

function EmuScreen:render()
    PZFBInputPanel.render(self)
    if self.fb and PZFB.isReady(self.fb) then
        self:drawTextureScaled(PZFB.getTexture(self.fb), 0, 0, 512, 512, 1, 1, 1, 1)
    end
end

function EmuScreen:onPZFBKeyDown(key)
    if key == Keyboard.KEY_ESCAPE then
        self:close()
    end
end

function EmuScreen:onPZFBCaptureToggle(active)
    -- Draw border glow or "INPUT LOCKED" indicator
end

function EmuScreen:close()
    if self.fb then PZFB.destroy(self.fb) end
    PZFBInputPanel.close(self)   -- releases capture + removes listeners (panel's own close)
    self:removeFromUIManager()
end

If EmuScreen were a child of an ISCollapsableWindow instead, the window's close() would call self.emuScreen:cleanup() before self:removeFromUIManager().

Full Example — Video Player (Selective)

local VideoUI = PZFBInputPanel:derive("VideoUI")

function VideoUI:new(x, y)
    local o = PZFBInputPanel.new(self, x, y, 640, 480, {
        mode = PZFBInput.MODE_SELECTIVE,
    })
    return o
end

function VideoUI:createChildren()
    PZFBInputPanel.createChildren(self)
    self:grabInput()
    -- Only capture playback keys — WASD still moves character
    self:captureKey(Keyboard.KEY_SPACE)       -- pause/play
    self:captureKey(Keyboard.KEY_LEFT)        -- seek back
    self:captureKey(Keyboard.KEY_RIGHT)       -- seek forward
    self:captureKey(Keyboard.KEY_UP)          -- volume up
    self:captureKey(Keyboard.KEY_DOWN)        -- volume down
end

function VideoUI:onPZFBKeyDown(key)
    if key == Keyboard.KEY_SPACE then
        -- toggle pause
    elseif key == Keyboard.KEY_LEFT then
        -- seek back 5s
    elseif key == Keyboard.KEY_RIGHT then
        -- seek forward 5s
    end
end