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.
Check if PZFB class files are deployed and functional.
- Returns:
boolean - When to use: Before creating framebuffers. Valid from load time (1.8.0);
OnGameStartstill works.
Events.OnGameStart.Add(function()
if not PZFB.isAvailable() then
print("PZFB not installed")
return
end
-- safe to create framebuffers
end)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.
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,gameStartArgsandstreamStartreturn 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)andstreamFrame(fb, idx, id): they act only if that id still owns the slot. - Status getters stay global (
gameStatus(),streamStatus(),streamWidth()…). CheckPZFB.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.
- Returns:
boolean— true iff the deployed class files reportColor.fbApiLevel() >= 2(pcall-guarded, cached once available). False with 1.7.x class files.
- Returns:
number|nil— id currently owning the slot (0= none), ornilwhen sessions are unsupported.
- Parameters:
id(number|nil) — the id your start call returned. - Returns:
boolean—id == PZFB.sessionId(). Returns true whenidis nil or sessions are unsupported, so the same code runs unchanged on 1.7 class files.
-- 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 = nilLegacy 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).
Create a framebuffer with NEAREST filtering (pixel-perfect, ideal for emulators and pixel art).
- Parameters:
width(number) — pixel widthheight(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 resolutionCreate a framebuffer with LINEAR filtering (smoother scaling, better for video playback).
- Parameters: same as
create() - Returns: same as
create()
Check if a framebuffer's GL texture has been allocated by the render thread.
- Parameters:
fb(table) — framebuffer handle fromcreate()
- Returns:
boolean - Note: Typically becomes ready within one frame (~16ms at 60fps). Poll in
render()orOnTick.
Fill the entire framebuffer with a solid RGBA color.
- Parameters:
fb(table) — framebuffer handler(number) — red, 0-255g(number) — green, 0-255b(number) — blue, 0-255a(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 transparentLoad raw RGBA pixel data from a file into the framebuffer.
- Parameters:
fb(table) — framebuffer handlepath(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 * 4bytes.
-- 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 |
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 handlepath(string) — absolute file path to concatenated raw RGBA dataframeIndex(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.readat 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
endGet 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)Get the underlying PZ Texture object for drawing.
- Parameters:
fb(table) — framebuffer handle
- Returns:
Texture|nil - Usage: Pass to
drawTextureScaled()in a UI panel'srender()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
endDestroy 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).
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.
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")Start playback from the beginning.
- Returns:
boolean— true if playback started
Pause playback. Position is preserved — call audioResume() to continue.
Resume playback from the paused position.
Stop playback and release the audio resource.
Set playback volume.
- Parameters:
volume(number) — 0.0 (silent) to 1.0 (full volume)
Seek to a position in milliseconds.
- Parameters:
positionMs(number) — target position in milliseconds
Get the current playback position.
- Returns:
number— current position in milliseconds
Get the total audio length.
- Returns:
number— total length in milliseconds
Check if audio is currently playing.
- Returns:
boolean
-- 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()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.
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 pixelsheight(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 withloadRawFrame())audio.ogg— extracted audio track (if source has audio)meta.txt— metadata (width, height, frames, fps, file paths)
Poll the conversion status.
- Returns:
number— 0=idle, 1=running, 2=done, 3=error
Get the error message from a failed conversion.
- Returns:
string— error message, or empty string
Reset the conversion status back to idle. Only works when not currently converting.
Check if ffmpeg is available on the system PATH.
- Returns:
boolean
-- 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()
endList 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..."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")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 pathdst(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
.exeuploads — bundle as.bin/.dat, copy to a working location at runtime, then launch viaPZFB.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)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).
- Parameters:
path(string) — absolute path to the video filescale(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())oncestreamStatus() >= 2.
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 discardedid(number|nil) — (1.8.0) session id; when given and supported, nothing is uploaded unlessidstill 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" viastreamStatus()/ownsSession(), never via a false return.
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.
- Parameters:
id(number|nil) — (1.8.0) with an id and 1.8 class files: stops only ifidowns the slot (returnstrue/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.
| 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) |
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.
Launch a game process. Legacy string-based form.
- Parameters:
binaryPath(string) — absolute path to the game binarywidth(number) — frame width in pixelsheight(number) — frame height in pixelsextraArgs(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:
extraArgsis 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), preferPZFB.gameStartArgs()(1.7.0+) which bypasses parsing entirely.
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 binarywidth(number) — frame width in pixelsheight(number) — frame height in pixelsargv(table|nil) — Lua array (1-based integer keys) of string arguments; each element is passed to the child process verbatim.niland 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, samegameSendInput/gameStatus/gameStoplifecycle. - 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,
})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.
- 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.
- Returns:
number— 0=idle, 1=starting, 2=running, 3=exited, 4=error. Global — describes whatever session owns the slot; checkPZFB.ownsSession(id)first.
- 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.
Stop the game process and free all resources. Safe to call multiple times.
- Parameters:
id(number|nil) — (1.8.0) session id fromgameStart/gameStartArgs. With an id (and 1.8 class files) the stop only happens if that id still owns the slot; returnstrue/false. Without an id: legacy stop of the current session if it is a game (1.8.0: agameStop()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 beTerminateProcess) → 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.
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.
-- 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)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
22869276and later), verified through 42.21 (1.8.0). The April 22 PZ patch refactored the gamepad-button API —isJoypadPressedwas renamed toJoypadButton.isButtonDown, and the per-controllergetJoypadAButton/BButton/XButton/YButton/LBumper/RBumper/BackButton/StartButton/LeftStickButton/RightStickButtonglobals were removed in favour ofJoypadButton.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.
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.
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 otherwiselocal 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()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 previousclose()/cleanup()removed them.)panel:releaseInput()— Stop capturing, restore all state (bindings, gamepad, joypad bind).panel:isCapturing()— Returnsboolean.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()andremoveFromUIManager()on the panel itself still clean up as before; callingcleanup()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()
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 releasefunction 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) endfunction 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-assignedonPZFBGamepadAxis 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.
panel:isKeyDown(key)— Is key currently held?panel:isModifierDown(name)—"shift","ctrl", or"alt"(checks both L+R variants).
panel:getMousePos()— Returnsx, yrelative to panel.panel:isMouseButtonDown(btn)— 0=left, 1=right, 2=middle.
panel:getGamepadAxis(slot, name)— Returns-1.0to1.0. Names:"leftX","leftY","rightX","rightY".panel:isGamepadDown(slot, button)— Joypad button constant.panel:isGamepadTriggerDown(slot, side)—"left"or"right".
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()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)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 pressSave/load action mappings and settings to ~/Zomboid/Lua/.
panel:saveInputConfig("mymod") -- writes PZFB_input_mymod.cfg
panel:loadInputConfig("mymod") -- reads and appliesInput 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.
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.
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()
endIf EmuScreen were a child of an ISCollapsableWindow instead, the window's close() would call self.emuScreen:cleanup() before self:removeFromUIManager().
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