--[[ GG Freeroam Client Addon Real photos and videos for the phone, Projector screens, shared views in FaceTime. Needs MoonLoader, SAMPFUNCS and SAMP.lua (samp.events), Windows 10 or newer Install: put this file in moonloader\ ]] script_name('GG Freeroam Client Addon') script_author('Freeroam.gg') local VERSION = 13 local sampev = require 'samp.events' local ffi = require 'ffi' local dlstatus = require('moonloader').download_status local VIDEO_STRUCT = [[ typedef struct { volatile int32_t state, stop, gone; uint16_t url[1024]; double seek; int32_t w, h, rate, chans, hasAudio; uint8_t *fb[6]; volatile int32_t fready[6]; double fpts[6]; uint8_t *ring; int32_t ringSize; volatile uint32_t aw, ar; volatile int32_t haveA; double apts0; volatile double clock; char err[128]; } ggfa_video; ]] ffi.cdef(VIDEO_STRUCT .. [[ typedef struct { int32_t pitch; void *bits; } ggfa_LOCKED_RECT; typedef struct { float x, y, z, rhw; uint32_t color; float u, v; } ggfa_VERTEX; typedef struct { unsigned long cb; char* reserved; char* desktop; char* title; unsigned long x, y, xSize, ySize, xChars, yChars, fill, flags; unsigned short showWindow, reserved2; void* reserved3; void* stdIn; void* stdOut; void* stdErr; } STARTUPINFOA; typedef struct { void* process; void* thread; unsigned long processId, threadId; } PROCESS_INFORMATION; int CreateProcessA(const char* app, char* cmd, void* pa, void* ta, int inherit, unsigned long flags, void* env, const char* dir, STARTUPINFOA* si, PROCESS_INFORMATION* pi); int CloseHandle(void* handle); void *CreateThread(void *attr, size_t stack, void *start, void *arg, uint32_t flags, uint32_t *id); void *GetProcessHeap(void); void *HeapAlloc(void *heap, uint32_t flags, size_t bytes); int HeapFree(void *heap, uint32_t flags, void *mem); int MultiByteToWideChar(uint32_t cp, uint32_t flags, const char *s, int n, uint16_t *w, int wn); typedef struct ggfa_L ggfa_L; ggfa_L *luaL_newstate(void); void luaL_openlibs(ggfa_L *L); int luaL_loadbuffer(ggfa_L *L, const char *buff, size_t sz, const char *name); int lua_pcall(ggfa_L *L, int nargs, int nresults, int errfunc); double lua_tonumber(ggfa_L *L, int idx); const char *lua_tolstring(ggfa_L *L, int idx, size_t *len); void lua_close(ggfa_L *L); uint32_t __stdcall BASS_StreamCreate(uint32_t freq, uint32_t chans, uint32_t flags, void *proc, void *user); uint32_t __stdcall BASS_StreamPutData(uint32_t handle, const void *buffer, uint32_t length); int __stdcall BASS_ChannelPlay(uint32_t handle, int restart); int __stdcall BASS_ChannelPause(uint32_t handle); int __stdcall BASS_ChannelSetAttribute(uint32_t handle, uint32_t attrib, float value); uint64_t __stdcall BASS_ChannelGetPosition(uint32_t handle, uint32_t mode); uint32_t __stdcall BASS_ChannelGetData(uint32_t handle, void *buffer, uint32_t length); int __stdcall BASS_StreamFree(uint32_t handle); ]]) local JPEG_SCREEN = ffi.cast('void(__cdecl*)(void*, const char*)', 0x5D0820) local SCENE_CAMERA = ffi.cast('void**', 0xC1703C) local HEAP = ffi.C.GetProcessHeap() local lua51 = ffi.load('lua51') local okBass, bass = pcall(ffi.load, 'bass') -- Photos, clip frames and pictures pass through here - made the first time one does local DIR = getWorkingDirectory() .. '\\GGFreeroamClientAddon' local function dir() if not doesDirectoryExist(DIR) then createDirectory(DIR) end return DIR end local helloSent = false local noAudioSent = false local picture = nil local screens = {} local rects = {} local dying = {} local capture = nil local updating = false local reloadNow = false local reported = {} -- The phone on the game screen (640 x 448), while it is up: the Projector is drawn around it, never over it local phoneRect = nil -- The Projector to draw this frame ({polygons, texture, u scale, v scale}): worked out in main, drawn in onD3DPresent - -- Direct3D straight from main re-entered MoonLoader ("cannot resume non-suspended coroutine") local pending = nil -- An error is written to moonloader.log once, and the addon keeps running: a dead script would also stop hiding -- the server's GGFRAD lines from the chat local function safely(what, fn, ...) local ok, err = pcall(fn, ...) if not ok and not reported[what] then reported[what] = true print('[GG Freeroam Client Addon] ' .. what .. ': ' .. tostring(err)) end end -- The decoding thread: Windows' Media Foundation reads the video from the server - pictures as RGB32 into six frame -- buffers (each a ready-made BMP), sound as 16-bit PCM into a ring - and stays a little ahead of v.clock local WORKER = [==[ local ffi = require 'ffi' local bit = require 'bit' ffi.cdef(VIDEO_STRUCT .. [[ typedef struct { uint32_t a; uint16_t b, c; uint8_t d[8]; } ggfa_GUID; typedef struct { uint16_t vt, r1, r2, r3; int64_t value; } ggfa_PROPVARIANT; long CoInitializeEx(void *reserved, uint32_t mode); void CoUninitialize(void); long MFStartup(uint32_t version, uint32_t flags); long MFShutdown(void); long MFCreateAttributes(void **attr, uint32_t size); long MFCreateMediaType(void **type); long MFCreateSourceReaderFromURL(const uint16_t *url, void *attr, void **reader); void Sleep(uint32_t ms); void *GetProcessHeap(void); void *HeapAlloc(void *heap, uint32_t flags, size_t bytes); ]]) local C = ffi.C local ole, mf, mfr = ffi.load('ole32'), ffi.load('mfplat'), ffi.load('mfreadwrite') local function guid(s) local h = s:gsub('-', '') local g = ffi.new('ggfa_GUID') g.a, g.b, g.c = tonumber(h:sub(1, 8), 16), tonumber(h:sub(9, 12), 16), tonumber(h:sub(13, 16), 16) for i = 0, 7 do g.d[i] = tonumber(h:sub(17 + i * 2, 18 + i * 2), 16) end return g end local MAJOR = guid('48eba18e-f8c9-4687-bf11-0a74c9f96a8f') local SUBTYPE = guid('f7e34c9a-42e8-4714-b74b-cb29d72c35e5') local VIDEO = guid('73646976-0000-0010-8000-00aa00389b71') local AUDIO = guid('73647561-0000-0010-8000-00aa00389b71') local RGB32 = guid('00000016-0000-0010-8000-00aa00389b71') local PCM = guid('00000001-0000-0010-8000-00aa00389b71') local VIDEO_PROCESSING = guid('fb394f3d-ccf1-42ee-bbb3-f9b845d5681d') local FRAME_SIZE = guid('1652c33d-d6b2-4012-b834-72030849a37d') local DEFAULT_STRIDE = guid('644b4e48-1e02-4516-b0eb-c01ca9d49ac6') local CHANNELS = guid('37e48bf5-645e-4c5b-89de-ada9e29b696a') local SAMPLE_RATE = guid('5faeeae7-0290-4c31-9e8a-c534f68d9dba') local BITS = guid('f2deb57f-40fa-4764-aa33-ed4f2d1ff669') local NULL_GUID = ffi.new('ggfa_GUID') local FIRST_VIDEO, FIRST_AUDIO = 0xFFFFFFFC, 0xFFFFFFFD -- One ctype per signature: LuaJIT never reuses a function type, a new one per cast fills its type table ("table overflow") local types = {} local function method(obj, i, sig) local t = types[sig] if not t then t = ffi.typeof(sig) types[sig] = t end return ffi.cast(t, ffi.cast('void***', obj)[0][i]) end local function release(obj) if obj ~= nil then method(obj, 2, 'uint32_t(__stdcall*)(void*)')(obj) end end local function setGuid(obj, key, value) return method(obj, 24, 'long(__stdcall*)(void*, const ggfa_GUID*, const ggfa_GUID*)')(obj, key, value) end local function setU32(obj, key, value) return method(obj, 21, 'long(__stdcall*)(void*, const ggfa_GUID*, uint32_t)')(obj, key, value) end local function getU32(obj, key, out) return method(obj, 7, 'long(__stdcall*)(void*, const ggfa_GUID*, uint32_t*)')(obj, key, out) end local function getU64(obj, key, out) return method(obj, 8, 'long(__stdcall*)(void*, const ggfa_GUID*, uint64_t*)')(obj, key, out) end local function check(hr, what) if hr < 0 then error(('%s failed (0x%08X)'):format(what, bit.band(hr, 0xFFFFFFFF) % 0x100000000)) end end local function run(v) check(ole.CoInitializeEx(nil, 0), 'CoInitializeEx') check(mf.MFStartup(0x20070, 0), 'MFStartup') local p = ffi.new('void*[1]') check(mf.MFCreateAttributes(p, 1), 'MFCreateAttributes') local attr = p[0] setU32(attr, VIDEO_PROCESSING, 1) check(mfr.MFCreateSourceReaderFromURL(v.url, attr, p), 'opening the video') release(attr) local reader = p[0] local setType = method(reader, 7, 'long(__stdcall*)(void*, uint32_t, uint32_t*, void*)') local getType = method(reader, 6, 'long(__stdcall*)(void*, uint32_t, void**)') local readSample = method(reader, 9, 'long(__stdcall*)(void*, uint32_t, uint32_t, uint32_t*, uint32_t*, int64_t*, void**)') check(mf.MFCreateMediaType(p), 'MFCreateMediaType') setGuid(p[0], MAJOR, VIDEO) setGuid(p[0], SUBTYPE, RGB32) check(setType(reader, FIRST_VIDEO, nil, p[0]), 'the video as RGB32') release(p[0]) check(mf.MFCreateMediaType(p), 'MFCreateMediaType') setGuid(p[0], MAJOR, AUDIO) setGuid(p[0], SUBTYPE, PCM) setU32(p[0], BITS, 16) v.hasAudio = setType(reader, FIRST_AUDIO, nil, p[0]) >= 0 and 1 or 0 release(p[0]) local u64, u32 = ffi.new('uint64_t[1]'), ffi.new('uint32_t[1]') check(getType(reader, FIRST_VIDEO, p), 'the video type') getU64(p[0], FRAME_SIZE, u64) v.w, v.h = tonumber(u64[0] / 4294967296ULL), tonumber(u64[0] % 4294967296ULL) local stride = v.w * 4 if getU32(p[0], DEFAULT_STRIDE, u32) >= 0 then stride = ffi.cast('int32_t', u32[0]) end stride = tonumber(stride) release(p[0]) if v.hasAudio == 1 and getType(reader, FIRST_AUDIO, p) >= 0 then getU32(p[0], SAMPLE_RATE, u32) v.rate = u32[0] getU32(p[0], CHANNELS, u32) v.chans = u32[0] release(p[0]) end -- Each frame buffer is a whole BMP (top-down, 32 bit), so the game loads it as it is local w, h = v.w, v.h local size = 54 + w * h * 4 local heap = C.GetProcessHeap() for i = 0, 5 do local b = ffi.cast('uint8_t*', C.HeapAlloc(heap, 8, size)) b[0], b[1] = 66, 77 ffi.cast('uint32_t*', b + 2)[0] = size ffi.cast('uint32_t*', b + 10)[0] = 54 ffi.cast('uint32_t*', b + 14)[0] = 40 ffi.cast('int32_t*', b + 18)[0] = w ffi.cast('int32_t*', b + 22)[0] = -h ffi.cast('uint16_t*', b + 26)[0] = 1 ffi.cast('uint16_t*', b + 28)[0] = 32 ffi.cast('uint32_t*', b + 34)[0] = w * h * 4 v.fb[i] = b end if v.seek > 0 then local at = ffi.new('ggfa_PROPVARIANT') at.vt, at.value = 20, v.seek * 1e7 method(reader, 8, 'long(__stdcall*)(void*, const ggfa_GUID*, const ggfa_PROPVARIANT*)')(reader, NULL_GUID, at) end v.state = 1 local index, flags, ts, sample, buffer = ffi.new('uint32_t[1]'), ffi.new('uint32_t[1]'), ffi.new('int64_t[1]'), ffi.new('void*[1]'), ffi.new('void*[1]') local data, length = ffi.new('uint8_t*[1]'), ffi.new('uint32_t[1]') local videoDone, audioDone = false, v.hasAudio == 0 local lastVideo = -1 local bytesPerSecond = v.rate * v.chans * 2 local function locked(s, fn) method(s, 41, 'long(__stdcall*)(void*, void**)')(s, buffer) method(buffer[0], 3, 'long(__stdcall*)(void*, uint8_t**, uint32_t*, uint32_t*)')(buffer[0], data, nil, length) fn(data[0], length[0]) method(buffer[0], 4, 'long(__stdcall*)(void*)')(buffer[0]) release(buffer[0]) release(s) end while v.stop == 0 and not (videoDone and audioDone) do local worked = false if not audioDone and v.aw - v.ar < bytesPerSecond * 1.5 then local hr = readSample(reader, FIRST_AUDIO, 0, index, flags, ts, sample) if hr < 0 or bit.band(flags[0], 2) ~= 0 then audioDone = true end if hr >= 0 and sample[0] ~= nil then if v.haveA == 0 then v.apts0, v.haveA = tonumber(ts[0]) / 1e7, 1 end locked(sample[0], function(src, n) while v.ringSize - (v.aw - v.ar) < n and v.stop == 0 do C.Sleep(5) end while n > 0 do local pos = v.aw % v.ringSize local k = math.min(n, v.ringSize - pos) ffi.copy(v.ring + pos, src, k) v.aw, src, n = v.aw + k, src + k, n - k end end) end worked = true end if not videoDone and lastVideo < v.clock + 0.6 then local slot for i = 0, 5 do if v.fready[i] == 0 then slot = i break end end if slot then local hr = readSample(reader, FIRST_VIDEO, 0, index, flags, ts, sample) if hr < 0 or bit.band(flags[0], 2) ~= 0 then videoDone = true end if hr >= 0 and sample[0] ~= nil then locked(sample[0], function(src) local dst = ffi.cast('uint32_t*', v.fb[slot] + 54) local pitch = math.abs(stride) for row = 0, h - 1 do local s = ffi.cast('uint32_t*', src + (stride >= 0 and row or h - 1 - row) * pitch) local d = dst + row * w -- RGB32 leaves the 4th byte 0: opaque, or the game would draw nothing for x = 0, w - 1 do d[x] = bit.bor(s[x], 0xFF000000) end end end) lastVideo = tonumber(ts[0]) / 1e7 v.fpts[slot] = lastVideo v.fready[slot] = 1 end worked = true end end if not worked then C.Sleep(4) end end release(reader) mf.MFShutdown() ole.CoUninitialize() if v.stop == 0 then v.state = 2 end end GGFA_PROC = ffi.cast('uint32_t(__stdcall*)(void*)', function(arg) local v = ffi.cast('ggfa_video*', arg) local ok, err = pcall(run, v) if not ok then local text = tostring(err) ffi.copy(v.err, text, math.min(#text, 127)) v.state = 3 end v.gone = 1 return 0 end) return tonumber(ffi.cast('uintptr_t', GGFA_PROC)) ]==] WORKER = 'local VIDEO_STRUCT = [[' .. VIDEO_STRUCT .. ']]\n' .. WORKER local function serverUrl(base) if base:match('^http://:') then return (base:gsub('^http://:', 'http://' .. sampGetCurrentServerAddress() .. ':')) end return base end local function upload(file, url) local cmd = ffi.new('char[?]', 1024) ffi.copy(cmd, ('curl.exe -s -m 20 -H "Content-Type: image/jpeg" --data-binary "@%s" "%s"'):format(file, serverUrl(url))) local si = ffi.new('STARTUPINFOA') si.cb = ffi.sizeof(si) local pi = ffi.new('PROCESS_INFORMATION') if ffi.C.CreateProcessA(nil, cmd, nil, nil, 0, 0x08000000, nil, nil, si, pi) ~= 0 then ffi.C.CloseHandle(pi.process) ffi.C.CloseHandle(pi.thread) end end -- ------------------------------------------------------------------ Direct3D, straight on the game's device -- (MoonLoader's renderBegin quads drew the video white: its textures never reached them). Drawn from onD3DPresent, -- as mimgui does -- Each signature's ctype made once: LuaJIT never reuses a function type, so a cast from a string every frame filled its -- type table within a second ("table overflow") and nothing was drawn local T = { call = ffi.typeof('long(__stdcall*)(void*)'), release = ffi.typeof('uint32_t(__stdcall*)(void*)'), u32 = ffi.typeof('long(__stdcall*)(void*, uint32_t)'), ptr = ffi.typeof('long(__stdcall*)(void*, void*)'), set2 = ffi.typeof('long(__stdcall*)(void*, uint32_t, uint32_t)'), set3 = ffi.typeof('long(__stdcall*)(void*, uint32_t, uint32_t, uint32_t)'), texture = ffi.typeof('long(__stdcall*)(void*, uint32_t, void*)'), stateBlock = ffi.typeof('long(__stdcall*)(void*, uint32_t, void**)'), createTexture = ffi.typeof('long(__stdcall*)(void*, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t, void**, void*)'), lockRect = ffi.typeof('long(__stdcall*)(void*, uint32_t, ggfa_LOCKED_RECT*, const void*, uint32_t)'), drawUP = ffi.typeof('long(__stdcall*)(void*, uint32_t, uint32_t, const void*, uint32_t)'), } -- The game's IDirect3DDevice9, as MoonLoader hands it out (mimgui takes it the same way) local function device() local dev = ffi.cast('void*', getD3DDevicePtr()) return dev, ffi.cast('void***', dev)[0] end local function release(obj) ffi.cast(T.release, ffi.cast('void***', obj)[0][2])(obj) end -- Power-of-two sides (some cards need them); returns the texture and how much of it the picture fills local function createTexture(w, h) local tw, th = 1, 1 while tw < w do tw = tw * 2 end while th < h do th = th * 2 end local dev, vt = device() local out = ffi.new('void*[1]') -- X8R8G8B8 (Media Foundation's RGB32) in the managed pool: it survives an alt-tab local hr = ffi.cast(T.createTexture, vt[23])(dev, tw, th, 1, 0, 22, 1, out, nil) if hr < 0 then return nil end return out[0], w / tw, h / th end local function uploadFrame(tex, src, w, h) local vt = ffi.cast('void***', tex)[0] local rect = ffi.new('ggfa_LOCKED_RECT') if ffi.cast(T.lockRect, vt[19])(tex, 0, rect, nil, 0) < 0 then return end local dst = ffi.cast('uint8_t*', rect.bits) for row = 0, h - 1 do ffi.copy(dst + row * rect.pitch, src + row * w * 4, w * 4) end ffi.cast(T.u32, vt[20])(tex, 0) end -- Polygons of {x, y, u, v} (pixels, 0..1) as triangle fans; black without a texture. The game's state is saved -- first and put back after local function drawPolys(polys, tex, us, vs) local dev, vt = device() local block = ffi.new('void*[1]') -- D3DSBT_ALL; made and dropped each time (one kept would stop the game's device reset on alt-tab) if ffi.cast(T.stateBlock, vt[59])(dev, 1, block) < 0 then return end local setState, setStage, setSampler = ffi.cast(T.set2, vt[57]), ffi.cast(T.set3, vt[67]), ffi.cast(T.set3, vt[69]) -- No depth test or writes, no blending, alpha test, culling, lighting or fog setState(dev, 7, 0) setState(dev, 14, 0) setState(dev, 27, 0) setState(dev, 15, 0) setState(dev, 22, 1) setState(dev, 137, 0) setState(dev, 28, 0) -- No shaders: the fixed pipeline, vertices already in screen pixels (XYZRHW | DIFFUSE | TEX1) -- SetVertexShader (92), SetPixelShader (107: after the six vertex shader constant methods and the stream ones) ffi.cast(T.ptr, vt[92])(dev, nil) ffi.cast(T.ptr, vt[107])(dev, nil) ffi.cast(T.u32, vt[89])(dev, 0x144) -- Stage 0: texture times colour, smoothly filtered; stage 1 off setStage(dev, 0, 1, 4) setStage(dev, 0, 2, 2) setStage(dev, 0, 3, 0) setStage(dev, 0, 4, 4) setStage(dev, 0, 5, 2) setStage(dev, 0, 6, 0) setStage(dev, 1, 1, 1) setSampler(dev, 0, 5, 2) setSampler(dev, 0, 6, 2) ffi.cast(T.texture, vt[65])(dev, 0, tex) local drawUP = ffi.cast(T.drawUP, vt[83]) for _, poly in ipairs(polys) do if #poly >= 3 then local verts = ffi.new('ggfa_VERTEX[?]', #poly) for i, p in ipairs(poly) do local q = verts[i - 1] q.x, q.y, q.z, q.rhw = p[1] - 0.5, p[2] - 0.5, 0, 1 q.color = tex and 0xFFFFFFFF or 0xFF000000 q.u, q.v = p[3] * us, p[4] * vs end -- D3DPT_TRIANGLEFAN drawUP(dev, 6, #poly - 2, verts, ffi.sizeof('ggfa_VERTEX')) end end local sb = ffi.cast('void***', block[0])[0] ffi.cast(T.call, sb[5])(block[0]) ffi.cast(T.release, sb[2])(block[0]) end -- ------------------------------------------------------------------ the video player (Projector screens) local function openVideo(url, seek) local v = ffi.cast('ggfa_video*', ffi.C.HeapAlloc(HEAP, 8, ffi.sizeof('ggfa_video'))) ffi.C.MultiByteToWideChar(65001, 0, url, -1, v.url, 1023) v.seek, v.clock = seek, seek v.ringSize = 48000 * 2 * 2 * 3 v.ring = ffi.cast('uint8_t*', ffi.C.HeapAlloc(HEAP, 8, v.ringSize)) local L = lua51.luaL_newstate() lua51.luaL_openlibs(L) if lua51.luaL_loadbuffer(L, WORKER, #WORKER, 'ggfa_video') ~= 0 or lua51.lua_pcall(L, 0, 1, 0) ~= 0 then print('[GG Freeroam Client Addon] Video: ' .. ffi.string(lua51.lua_tolstring(L, -1, nil))) lua51.lua_close(L) ffi.C.HeapFree(HEAP, 0, ffi.cast('void*', v.ring)) ffi.C.HeapFree(HEAP, 0, ffi.cast('void*', v)) return nil end print('[GG Freeroam Client Addon] Video: opening ' .. url .. ' at ' .. seek .. ' s') local thread = ffi.C.CreateThread(nil, 0, ffi.cast('void*', lua51.lua_tonumber(L, -1)), ffi.cast('void*', v), 0, nil) ffi.C.CloseHandle(thread) return { v = v, L = L, url = url, stream = 0 } end local function closeVideo(p) p.v.stop = 1 if p.stream ~= 0 then bass.BASS_StreamFree(p.stream) end -- Never drawn again after this pending = nil if p.tex then release(p.tex) end dying[#dying + 1] = p end -- A finished thread's memory and state, a moment after it said it is gone local function reap() for i = #dying, 1, -1 do local p = dying[i] if p.v.gone == 1 then p.goneAt = p.goneAt or os.clock() if os.clock() - p.goneAt > 2 then -- Off the list first: whatever happens below, nothing is freed twice table.remove(dying, i) for k = 0, 5 do if p.v.fb[k] ~= nil then ffi.C.HeapFree(HEAP, 0, ffi.cast('void*', p.v.fb[k])) end end ffi.C.HeapFree(HEAP, 0, ffi.cast('void*', p.v.ring)) lua51.lua_close(p.L) ffi.C.HeapFree(HEAP, 0, ffi.cast('void*', p.v)) end end end end -- Where the owner's clock says the video is now (seconds; below 0: not started yet) local function target(s) if s.paused then return s.offset / 1000 end return (s.offset + (os.clock() - s.at) * 1000) / 1000 end local function playVideo(s, distance) local p = s.player local v = p.v if v.state == 3 then print('[GG Freeroam Client Addon] Video: ' .. ffi.string(v.err)) closeVideo(p) s.player, s.failed = nil, true return end if v.state < 1 then return end if not p.told then p.told = true print(('[GG Freeroam Client Addon] Video: decoding %dx%d, sound %s'):format(v.w, v.h, v.hasAudio == 1 and (v.rate .. ' Hz, ' .. v.chans .. ' ch') or 'none')) end local t = target(s) local bytesPerSecond = v.rate * v.chans * 2 if v.hasAudio == 1 and p.stream == 0 and not p.noAudio then p.stream = okBass and bass.BASS_StreamCreate(v.rate, v.chans, 0, ffi.cast('void*', -1), nil) or 0 if p.stream == 0 then p.noAudio = true if not noAudioSent then noAudioSent = true sampSendChat('/ggfrad noaudio') end end end local audio = p.stream ~= 0 if not p.playing then -- Waits until the shared start, dropping what was decoded before it: everyone starts on the same frame if t < 0 or s.paused then return end if audio then -- (a seek lands on the keyframe at or after it: then wait for the clock to get there) if v.haveA == 0 or v.apts0 > t + 0.05 then return end local skip = math.floor((t - v.apts0) * bytesPerSecond / (v.chans * 2)) * v.chans * 2 if skip > 0 then local have = v.aw - v.ar if skip > have then v.ar = v.ar + have v.apts0 = v.apts0 + have / bytesPerSecond return end v.ar = v.ar + skip end p.base = math.max(t, v.apts0) bass.BASS_ChannelPlay(p.stream, 0) else p.base, p.startClock = t, os.clock() end p.playing = true print(('[GG Freeroam Client Addon] Video: playing from %.1f s (%s)'):format(t, audio and 'own sound' or 'no sound')) end local media if audio then while v.aw > v.ar and bass.BASS_ChannelGetData(p.stream, nil, 0) < bytesPerSecond / 2 do local pos = v.ar % v.ringSize local n = math.min(v.aw - v.ar, v.ringSize - pos) bass.BASS_StreamPutData(p.stream, v.ring + pos, n) v.ar = v.ar + n end media = p.base + tonumber(bass.BASS_ChannelGetPosition(p.stream, 0)) / bytesPerSecond if s.paused then bass.BASS_ChannelPause(p.stream) else bass.BASS_ChannelPlay(p.stream, 0) -- A small drift: up to 3 % faster or slower, unheard local drift = math.max(-0.03, math.min(0.03, media - t)) bass.BASS_ChannelSetAttribute(p.stream, 1, v.rate * (1 - drift)) end local fade = math.max(0, 1 - (distance / s.reach) ^ 2) bass.BASS_ChannelSetAttribute(p.stream, 2, s.volume / 100 * fade) else media = t end v.clock = media -- A big drift (a seek, a stall): open again at the right second if not s.paused and math.abs(media - t) > 1.5 then print(('[GG Freeroam Client Addon] Video: %.1f s off (at %.1f, should be %.1f) - opening again'):format(media - t, media, t)) closeVideo(p) s.player = nil return end local best for i = 0, 5 do if v.fready[i] == 1 and v.fpts[i] <= media + 0.02 and (not best or v.fpts[i] > v.fpts[best]) then best = i end end if best then if not p.tex and not p.texFailed then p.tex, p.us, p.vs = createTexture(v.w, v.h) if not p.tex then p.texFailed = true print('[GG Freeroam Client Addon] Video: no texture for ' .. v.w .. 'x' .. v.h) end end if p.tex then uploadFrame(p.tex, v.fb[best] + 54, v.w, v.h) end if not p.firstFrame then p.firstFrame = true -- The decoded picture as it is, to open in any image viewer local f = io.open(dir() .. '\\video_frame.bmp', 'wb') if f then f:write(ffi.string(v.fb[best], 54 + v.w * v.h * 4)) f:close() end print('[GG Freeroam Client Addon] Video: first frame at ' .. v.fpts[best] .. ' s, saved as GGFreeroamClientAddon\\video_frame.bmp') end for i = 0, 5 do if v.fready[i] == 1 and v.fpts[i] <= v.fpts[best] then v.fready[i] = 0 end end end end -- The part of a polygon ({x, y, u, v} points) on one side of a line: x (axis 1) or y (axis 2) below or above value local function clip(poly, axis, value, below) local out = {} local function inside(p) return below and p[axis] < value or not below and p[axis] >= value end for i = 1, #poly do local a, b = poly[i], poly[i % #poly + 1] if inside(a) then out[#out + 1] = a end if inside(a) ~= inside(b) then local k = (value - a[axis]) / (b[axis] - a[axis]) out[#out + 1] = { a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k, a[3] + (b[3] - a[3]) * k, a[4] + (b[4] - a[4]) * k } end end return out end local function drawScreen(s, p) local cx, cy, cz = getActiveCameraCoordinates() local px, py, pz = getActiveCameraPointAt() local fx, fy, fz = px - cx, py - cy, pz - cz local h = math.rad(s.heading) local nx, ny = -math.sin(h), math.cos(h) if (cx - s.x) * nx + (cy - s.y) * ny <= 0 then return end if not isLineOfSightClear(cx, cy, cz, s.x + nx * 0.5, s.y + ny * 0.5, s.z, true, false, false, false, false) then return end -- Its right for whoever looks at it (never mirrored), tilted back by its pitch local rx, ry = math.cos(h), math.sin(h) local tilt = math.rad(s.pitch) local ux, uy, uz = -nx * math.sin(tilt), -ny * math.sin(tilt), math.cos(tilt) local hw, hh = s.w / 2, s.h / 2 local corners = { { s.x + rx * hw + ux * hh, s.y + ry * hw + uy * hh, s.z + uz * hh, 0, 0 }, { s.x - rx * hw + ux * hh, s.y - ry * hw + uy * hh, s.z + uz * hh, 1, 0 }, { s.x - rx * hw - ux * hh, s.y - ry * hw - uy * hh, s.z - uz * hh, 1, 1 }, { s.x + rx * hw - ux * hh, s.y + ry * hw - uy * hh, s.z - uz * hh, 0, 1 }, } local points = {} for i, c in ipairs(corners) do if (c[1] - cx) * fx + (c[2] - cy) * fy + (c[3] - cz) * fz <= 0 then return end local sx, sy = convert3DCoordsToScreen(c[1], c[2], c[3]) points[i] = { sx, sy, c[4], c[5] } end -- What lies outside the phone: left of it, right of it, then above and below it between the two local pieces = { points } if phoneRect then local sw, sh = getScreenResolution() local l, r = phoneRect[1] * sw / 640, (phoneRect[1] + phoneRect[3]) * sw / 640 local t, b = phoneRect[2] * sh / 448, (phoneRect[2] + phoneRect[4]) * sh / 448 local rest = clip(points, 1, l, false) local middle = clip(rest, 1, r, true) pieces = { clip(points, 1, l, true), clip(rest, 1, r, false), clip(middle, 2, t, true), clip(middle, 2, b, false) } end pending = { pieces, p and p.tex, p and p.us or 1, p and p.vs or 1 } end -- Only the nearest screen plays local function updateScreens() pending = nil local x, y, z = getCharCoordinates(PLAYER_PED) local nearest, best = nil, math.huge for _, s in pairs(screens) do local d = getDistanceBetweenCoords3d(x, y, z, s.x, s.y, s.z) if d < s.reach + 20 and d < best then nearest, best = s, d end end for _, s in pairs(screens) do if s ~= nearest and s.player then closeVideo(s.player) s.player = nil end end if nearest then if not nearest.player and not nearest.failed and nearest.url then local t = target(nearest) -- Opening takes a moment: ask for a little further on when it already runs nearest.player = openVideo(nearest.url, t > 0 and t + 1.5 or 0) end if nearest.player then playVideo(nearest, best) end drawScreen(nearest, nearest.player) end reap() end local function dropScreen(id) local s = screens[id] if s and s.player then closeVideo(s.player) end screens[id] = nil end local function setScreen(f) local id = tonumber(f[2]) local s = screens[id] -- "-": nothing plays, the screen stays black local url = f[3] ~= '-' and serverUrl(f[3]) or nil if s and s.url ~= url then dropScreen(id) s = nil end s = s or { url = url } s.offset, s.paused, s.at = tonumber(f[4]), f[5] == '1', os.clock() s.x, s.y, s.z, s.heading, s.pitch = tonumber(f[6]), tonumber(f[7]), tonumber(f[8]), tonumber(f[9]), tonumber(f[10]) s.w, s.h, s.reach, s.volume = tonumber(f[11]), tonumber(f[12]), tonumber(f[13]), tonumber(f[14]) s.failed = false screens[id] = s end -- ------------------------------------------------------------------ photos, clips, shared views local function shot(id, token, base) lua_thread.create(function() wait(100) local file = dir() .. '\\shot_' .. id .. '.jpg' JPEG_SCREEN(SCENE_CAMERA[0], file) wait(300) upload(file, base .. '/photo/?t=' .. token) end) end local function startCapture(token, fps, count, base) local mine = { token = token } capture = mine lua_thread.create(function() local n = 0 while capture == mine and (count == 0 or n < count) do n = n + 1 local file = dir() .. '\\cap_' .. n .. '.jpg' JPEG_SCREEN(SCENE_CAMERA[0], file) upload(file, base .. '/cap/?t=' .. token .. '&n=' .. n) if n > 10 then os.remove(DIR .. '\\cap_' .. (n - 10) .. '.jpg') end wait(math.floor(1000 / fps)) end if capture == mine then capture = nil end end) end local function show(url) local file = dir() .. '\\view.jpg' if picture and picture.tex then renderReleaseTexture(picture.tex) end picture = { loading = true } local wanted = picture downloadUrlToFile(serverUrl(url), file, function(_, status) if status == dlstatus.STATUS_ENDDOWNLOADDATA and picture == wanted then picture = { file = file } end end) end local function hide() if picture and picture.tex then renderReleaseTexture(picture.tex) end picture = nil end local function frameOf(r) if r.live then return math.floor(os.clock() * 3) end local ms = r.paused and r.offset or r.offset + (os.clock() - r.at) * 1000 return math.max(1, math.min(r.frames, math.floor(ms * r.fps / 1000) + 1)) end local function fetchFrame(r, key) local frame = frameOf(r) if r.ready then local tex = renderLoadTextureFromFile(r.ready) if tex then if r.tex then renderReleaseTexture(r.tex) end r.tex, r.shown = tex, r.readyFrame end r.ready = nil end if r.loading and os.clock() - r.loading < 3 or frame == r.shown or (not r.live and r.frames == 0) then return end r.loading = os.clock() r.slot = 1 - r.slot local file = dir() .. '\\frame_' .. key .. '_' .. r.slot .. '.jpg' local url = r.live and (r.base .. 'latest.jpg?' .. frame) or (r.base .. frame .. '.jpg') downloadUrlToFile(url, file, function(_, status) if status == dlstatus.STATUS_ENDDOWNLOADDATA then r.ready, r.readyFrame, r.loading = file, frame, nil end end) end local function updateRects() local sw, sh = getScreenResolution() for id, r in pairs(rects) do fetchFrame(r, 'r' .. id) local x, y, w, h = r.x * sw / 640, r.y * sh / 448, r.w * sw / 640, r.h * sh / 448 renderDrawBox(x, y, w, h, 0xFF000000) if r.tex then renderDrawTexture(r.tex, x, y, w, h, 0, -1) end end end local function dropRect(id) local r = rects[id] if r and r.tex then renderReleaseTexture(r.tex) end rects[id] = nil end local function setRect(f) local id = tonumber(f[2]) local old = rects[id] local base = serverUrl(f[3]) local same = old and old.base == base if old and old.tex and not same then renderReleaseTexture(old.tex) end rects[id] = { base = base, fps = tonumber(f[4]), frames = tonumber(f[5]), offset = tonumber(f[6]), paused = f[7] == '1', live = f[8] == '1', x = tonumber(f[9]), y = tonumber(f[10]), w = tonumber(f[11]), h = tonumber(f[12]), at = os.clock(), slot = 0, tex = same and old.tex or nil, shown = same and old.shown or nil, } end -- ------------------------------------------------------------------ the server's lines, updates local function update(url) if updating then return end updating = true local path = thisScript().path local new = path .. '.new' downloadUrlToFile(serverUrl(url), new, function(_, status) if status ~= dlstatus.STATUS_ENDDOWNLOADDATA then return end local f = io.open(new, 'rb') local code = f and f:read('*a') or '' if f then f:close() end if not code:find("script_name('GG Freeroam Client Addon')", 1, true) then os.remove(new) updating = false return end os.remove(path) os.rename(new, path) print('[GG Freeroam Client Addon] Updated - reloading MoonLoader') reloadNow = true end) end local function split(text) local fields = {} for field in (text .. '|'):gmatch('([^|]*)|') do fields[#fields + 1] = field end return fields end local function handle(text) local f = split(text:sub(8)) local cmd = f[1] if cmd == 'vid' then setScreen(f) elseif cmd == 'vidoff' then dropScreen(tonumber(f[2])) elseif cmd == 'phone' then phoneRect = f[2] ~= 'off' and { tonumber(f[2]), tonumber(f[3]), tonumber(f[4]), tonumber(f[5]) } or nil elseif cmd == 'rect' then setRect(f) elseif cmd == 'rectoff' then dropRect(tonumber(f[2])) elseif cmd == 'cap' then startCapture(f[2], tonumber(f[3]), tonumber(f[4]), f[5]) elseif cmd == 'capstop' then capture = nil elseif cmd == 'shot' then shot(f[2], f[3], f[4]) elseif cmd == 'img' then if f[2] == 'off' then hide() else show(f[2]) end elseif cmd == 'update' then update(f[2]) end end function sampev.onServerMessage(color, text) if text:sub(1, 7) ~= 'GGFRAD|' then return end safely('Server line', handle, text) return false end local function reset() hide() capture = nil phoneRect = nil pending = nil for id in pairs(screens) do dropScreen(id) end for id in pairs(rects) do dropRect(id) end end function sampev.onSendClientJoin() helloSent = false reset() end function onD3DPresent() if pending and not updating then safely('Drawing', drawPolys, pending[1], pending[2], pending[3], pending[4]) end end function main() while not isSampAvailable() do wait(100) end while true do wait(0) if reloadNow then reset() -- All of MoonLoader, so the new copy starts clean reloadScripts() return end if not helloSent and sampIsLocalPlayerSpawned() then helloSent = true sampSendChat('/ggfrad hello ' .. VERSION) end -- While a new copy downloads, the old one plays nothing: its bugs never get the chance to stop the update if updating then pending = nil else safely('Projector', updateScreens) end safely('Pictures', updateRects) if picture and picture.file and not picture.tex then picture.tex = renderLoadTextureFromFile(picture.file) end if picture and picture.tex then local sw, sh = getScreenResolution() local top, bottom = sh * 22 / 448, sh * 410 / 448 renderDrawBox(0, top, sw, bottom - top, 0xFF000000) local h = bottom - top local w = math.min(sw, h * 16 / 9) renderDrawTexture(picture.tex, (sw - w) / 2, top, w, h, 0, -1) end end end