From 5f88c5e5db7633c74a3e01ad24e50751b1ec2e5d Mon Sep 17 00:00:00 2001 From: dop42 Date: Sat, 24 Oct 2026 20:16:58 +0200 Subject: [PATCH] fix(pool): register cooked visual hosts for all object balls --- config/pool.lua | 4 ++-- docs/MANUAL.md | 22 +++++++++++------ docs/POOL_MODELS.md | 44 ++++++++++++++++++++++++++++++++++ modules/pool/client/main.lua | 2 +- modules/pool/client/render.lua | 30 +++++++++++++++++++---- modules/pool/shared/model.lua | 1 + open77.lua | 2 +- tests/audit-pool-client.lua | 31 ++++++++++++++++++++++-- 8 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 docs/POOL_MODELS.md diff --git a/config/pool.lua b/config/pool.lua index 5a573b63..8f9995be 100644 --- a/config/pool.lua +++ b/config/pool.lua @@ -31,8 +31,8 @@ OPX.Config.MODULES.pool = { MODELS = { cue = 'recreation.tent', object = 'recreation.sleeping_bag', -- retained legacy profile field - -- Object balls use the fifteen vanilla ball_c..ball_q meshes by default. - -- Optional objects = { ... } must name exactly fifteen distinct models. + -- Object balls register fifteen cooked hosts from opx_pool_assets >=0.3.0. + -- Optional objects = { ... } must name fifteen distinct registered model IDs. stick = 'recreation.gym_equipment', -- Host-specific heading/pivot calibration, in degrees/metres. stickYaw = 0, stickOffset = 0.7, diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 1dbc5291..c2575d02 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -950,11 +950,18 @@ Contract `bar` v1 (server): `Venues()`, `Items()`, `DrunkLevel(player)`. Bus: `modules/pool` + `config/pool.lua` offers three original modes using one white cue ball and fifteen distinct vanilla object balls, arranged in a five-row triangle. The object balls use the game’s `ball_c_billiard.mesh` through `ball_q_billiard.mesh` -models via the documented [raw mesh prop backend](https://open2077.net/docs/props). -An optional `MODELS.objects` array supplies exactly fifteen model paths. Existing -configs without that field receive the full vanilla set automatically. Cue/white -hosts, recorded audio and cleanup -patches live in the separate `opx_pool_assets` resource. Table actions use menu-wide +models through fifteen cooked hosts in `opx_pool_assets` **0.3.0 or later**. Infinity +registers each mesh/visual-host pair using the resource-owned model registry on +**op77.152 or later**, then renders the opaque model ID returned by the native. +Raw mesh names alone displayed fallback cylinders on the deployed client. Missing +or refused registration now leaves the table unavailable with a friendly notice, +rather than spawning placeholders. Existing configs automatically use the full +registered set. Optional `MODELS.objects` overrides must supply fifteen distinct, +already registered model IDs or supported aliases. Cue/white hosts, recorded audio +and cleanup patches remain in the same asset resource. After upgrading the pack, +**fully close the game and launch it again through the launcher**; reconnecting +inside a running game does not mount the updated archive. See +[the version-specific native contract](POOL_MODELS.md). Table actions use menu-wide `closeOnSelect`, so selecting a mode dispatches its command before releasing focus. Confirmation submenus dispatch only their confirmed selection. The modes have no wagers, @@ -1059,7 +1066,7 @@ cannot take a shot. These modes have no economic effects or persisted game state The native baseline checked is Devkit `2.50.0+op77.148`; the test VPS uses `2.50.0+op77.152`. Install [`opx_pool_assets`](https://github.com/opx77-framework/opx_pool_assets) before Infinity. -Use asset resource version `0.2.0` or later. Its client script publishes the recorded +Use asset resource version `0.3.0` or later. Its client script publishes the recorded audio service and makes the resource available in the signed client set to satisfy Infinity's dependency. The service alone declares `audio.play`; Infinity does not request audio-network authority. @@ -1082,7 +1089,8 @@ required-mod trust/review policy; do not silently enable unsecured mode to insta a loader. Restart the server, fully quit Cyberpunk, and reconnect through the launcher: cooked archives cannot mount during a running game session. -The asset package deliberately reserves three native aliases globally: +The fifteen new `opx/pool/ball_*.ent` hosts have their own namespace and reserve +no additional global aliases. The legacy asset package reserves three aliases globally: `recreation.tent` (white), `recreation.sleeping_bag` (yellow), and `recreation.gym_equipment` (horizontal cue). Other resources must not use them for their original objects. Six normal/attached hosts are repacked from the supplied diff --git a/docs/POOL_MODELS.md b/docs/POOL_MODELS.md new file mode 100644 index 00000000..66b2181b --- /dev/null +++ b/docs/POOL_MODELS.md @@ -0,0 +1,44 @@ +# Pool model registry contract + +The native reference available during this fix describes op77.148 and does not +list model registration. The deployed, signed Open77 client is **2.50.0+op77.152**. +The full-rack renderer therefore checks the native exists and refuses the table +with an operator diagnostic and friendly notification on older clients. + +The inspected client DLL has SHA-256: +`f34f74c9c9c3267bc2fc6bb32fccf940772ecf608aba52c5c58202dd2afde74e`. +It came from the signed core manifest; no modified client executable is shipped. + +The Lua `Open77.props.registerModel` binding is registered at VA `0x180792657` +and implemented at VA `0x180759398` (preferred image base `0x180000000`). The +binding checks `world.props`, accepts exactly two arguments (string ID, table), +and accepts only the string fields `mesh`, `visualEntity`, `physicalEntity`. +The latter is optional; the mesh and visual entity must be depot `.mesh` and +`.ent` paths. It returns an opaque resource-owned model ID string on success, +or nil and a refusal such as `invalid_model_arguments`, `invalid_model_fields`, +`invalid_model_paths` or `model_registration_conflict`. Infinity uses the returned +string, never invents its format, and supplies no physical entity for pool balls. + +Each default pair is registered once per pool module start: + +```lua +local model, reason = Open77.props.registerModel('pool_ball_1', { + mesh = 'base\\environment\\decoration\\recreation\\sports\\ball\\ball_c_billiard.mesh', + visualEntity = 'opx\\pool\\ball_01.ent', +}) +``` + +`opx_pool_assets` 0.3.0 includes all fifteen visual hosts. They are deterministic +derivatives of the supplied, previously rendered object-ball template, each with +one changed vanilla mesh import. Their modified compiled-data buffer is stored +uncompressed; unchanged buffers, offsets, buffer/table/header CRC32 and archive +segment metadata are checked by the asset package tests. Only the original six +legacy host paths and the fifteen new `opx/pool/` paths are allowed in the archive. +No game meshes or textures, DLLs, unrelated alias overrides or legacy gameplay +scripts are added. + +A successful registration diagnostic proves the registry accepted the mapping, +not that the engine rendered it. The package requires a fresh game launch through +the launcher. Actual materials, visual scale and removal/rejoin behavior still +require a live game test; native doubles and binary validation do not establish +those visual results. diff --git a/modules/pool/client/main.lua b/modules/pool/client/main.lua index f4c11a95..c45b6af2 100644 --- a/modules/pool/client/main.lua +++ b/modules/pool/client/main.lua @@ -259,7 +259,7 @@ end function M.Start() R.Reset(); G.Reset() spec = Model.Table() - if not spec then return end + if not spec or not R.Register() then return end target, prompts = OPX.Api.Get('target'), OPX.Api.Get('prompts') if target then target.RegisterSpheres('pool', { { x = spec.x, y = spec.y, z = spec.z, radius = 2 } }, { id = 'practice', label = locale('pool.tableMenu'), icon = 'interact', distance = 3, diff --git a/modules/pool/client/render.lua b/modules/pool/client/render.lua index 44586f6b..808319f5 100644 --- a/modules/pool/client/render.lua +++ b/modules/pool/client/render.lua @@ -5,13 +5,13 @@ local Model = M.Model M.Render = {} local R = M.Render local ids, stick, created, failure, stroke = {}, nil, {}, nil, nil -local models +local models, registered function R.Clear() for index, id in pairs(ids) do Open77.props.remove(id); ids[index] = nil end if stick then Open77.props.remove(stick); stick = nil end created, stroke = {}, nil end -function R.Reset() R.Clear(); failure = nil; models = Model.Models() end +function R.Reset() R.Clear(); failure = nil; models = Model.Models(); registered = false end local function fail(why) if not failure then failure = tostring(why) @@ -21,6 +21,29 @@ local function fail(why) R.Clear() return false end +-- op77.152 registerModel(id, {mesh, visualEntity, physicalEntity?}) returns +-- an opaque resource-owned model ID. Cooked visual hosts are shipped by assets +-- 0.3.0; a raw .mesh name alone falls back to a marker on this client. +function R.Register() + if failure then return false end + if registered then return true end + if not models then return fail('invalid model configuration') end + if models.registerObjects then + if type(Open77.props.registerModel) ~= 'function' then return fail('prop model registration requires op77.152 or later') end + for number = 1, Model.BALLS-1 do + local ok, model, why = pcall(Open77.props.registerModel, 'pool_ball_' .. number, { + mesh = models.objects[number], visualEntity = ('opx\\pool\\ball_%02d.ent'):format(number), + }) + if not ok or type(model) ~= 'string' or #model == 0 or #model > 256 or model:find('%c') then + return fail('registerModel ball ' .. number .. ': ' .. tostring(ok and (why or model) or model)) + end + models.objects[number] = model + end + end + registered = true + if models.registerObjects then OPX.Note('pool', 'registered fifteen cooked visual ball hosts') end + return true +end local function make(model, position, yaw) local id, why = Open77.props.create({ model = model, position = position, yaw = yaw or 0, collision = false, physics = "none", visible = true }) if not id then return nil, why end @@ -48,8 +71,7 @@ function R.StickTransform(spec, ball, angle, power, forward) end function R.Draw(spec, positions, aiming, angle, power) if failure then return false end - models = models or Model.Models() - if not models then return fail('invalid model configuration') end + if not R.Register() then return false end for index, ball in ipairs(positions) do if ball[3] then local position = Model.World(spec, ball[1], ball[2]) diff --git a/modules/pool/shared/model.lua b/modules/pool/shared/model.lua index d91a4306..b01f67e1 100644 --- a/modules/pool/shared/model.lua +++ b/modules/pool/shared/model.lua @@ -44,6 +44,7 @@ function Model.Models() result[name] = path end result.objects = {} + result.registerObjects = raw.objects == nil local used = {} if raw.objects ~= nil and (type(raw.objects) ~= 'table' or #raw.objects ~= Model.BALLS-1) then return nil end for number = 1, Model.BALLS-1 do diff --git a/open77.lua b/open77.lua index 440177a8..8cea1d54 100644 --- a/open77.lua +++ b/open77.lua @@ -53,7 +53,7 @@ auto_start true -- and puts it on `OPX.Lib`; see that file for why only the client half uses it. dependency "opx_lib" -- Cooked pool hosts and table cleanup are mounted by the launcher before boot. -dependency "opx_pool_assets >=0.2.0" +dependency "opx_pool_assets >=0.3.0" reload_policy "reconnect" diff --git a/tests/audit-pool-client.lua b/tests/audit-pool-client.lua index 11e3b101..5fd177ba 100644 --- a/tests/audit-pool-client.lua +++ b/tests/audit-pool-client.lua @@ -9,8 +9,22 @@ end env.Open77.players.localId = function() return 7 end env.Open77.character.position = function() return 99,10,1 end env.Open77.input.axis = function() return mouse end +local registrations, registeredModels, registrationRefusal = {}, {}, nil env.Open77.props = { + registerModel = function(id, definition) + if registrationRefusal then return nil,registrationRefusal end + assert(id:match('^pool_ball_%d+$') and type(definition)=='table','invalid registration arguments') + local number=tonumber(id:match('%d+$')) + assert(definition.mesh:match('ball_'..string.char(98+number)..'_billiard%.mesh$'),'wrong mesh in registration') + assert(definition.visualEntity==('opx\\pool\\ball_%02d.ent'):format(number),'wrong cooked host') + assert(definition.physicalEntity==nil,'visual pool ball acquired a physical host') + local result='opaque-registered-model:'..id + registrations[id]=definition;registeredModels[result]=true + return result + end, create = function(options) + assert(not options.model:match('%.mesh$'),'raw mesh bypassed its cooked host') + assert(options.model=='recreation.tent' or options.model=='recreation.gym_equipment' or registeredModels[options.model],'renderer invented a registration ID') local modes={static=true,kinematic=true,dynamic=true,none=true} if not modes[options.physics] then return nil,'invalid_physics' end assert(options.collision==false and options.physics=='none' and options.visible==true,'pool visual prop acquired collision/physics') @@ -65,6 +79,8 @@ local drawnModels={} for _,prop in pairs(props)do drawnModels[prop.model]=true end local distinct=0;for _ in pairs(drawnModels)do distinct=distinct+1 end assert(distinct==16,'object balls reused the same yellow model') +local registeredCount=0;for _ in pairs(registrations) do registeredCount=registeredCount+1 end +assert(registeredCount==15,'not all fifteen cooked hosts were registered') assert(creates==16 and state.allowShoot==false and state.allowAim==false,'practice did not render/hold weapons') M.Client.Frame(); M.Client.Guide(); M.Client.Strip() assert(cameraHeld and state.freezePosition==true and guideShown,'entry did not arm a camera/guide') @@ -72,12 +88,12 @@ control.input.down.V=true;control.Advance(20);M.Client.Frame();control.input.dow assert(cameraFov==52,'inventory V key switched pool view') control.input.down.mouse3=true;control.Advance(20);M.Client.Frame();control.input.down.mouse3=false assert(cameraFov>52,'middle click did not switch pool view') -local function measured(fn) +local function measured(fn, ceiling) local cost=0 debug.sethook(function() cost=cost+100 end,'',100) local ok,why=pcall(fn) debug.sethook() - assert(ok,why); assert(cost<5000,'client resume exceeds full-rack budget: '..cost) + assert(ok,why); assert(cost<(ceiling or 5000),'client resume exceeds full-rack budget: '..cost) return cost end local worst=0 @@ -173,6 +189,17 @@ local accepted=packet(2,50);accepted.epoch=13;accepted.game=M.Match.New(choice.m M.Client.Receive(accepted);M.Client.Frame();M.Client.Frame() assert(cameraHeld,'real menu selection failed to arm camera') M.Stop() +-- Registration of all fifteen hosts stays inside one client resume. +M.Stop();M.Init();worst=math.max(worst,measured(M.Start,8000));M.Stop() +-- A missing/refused registry must never spawn raw meshes or placeholders. +M.Stop();registrationRefusal='model_registration_conflict' +local beforeRefusal=creates +M.Init();M.Start();M.Client.Watch();M.Client.Draw() +assert(M.Render.Failed() and creates==beforeRefusal,'registration refusal spawned fallback cylinders') +M.Stop();registrationRefusal=nil +local registry=env.Open77.props.registerModel;env.Open77.props.registerModel=nil +M.Init();M.Start();assert(M.Render.Failed(),'old client silently spawned unsupported models');M.Stop() +env.Open77.props.registerModel=registry -- A just-created native entity may not be transformable until streaming ends. M.Render.Reset(); failed=false local geometry={x=100,y=10,z=1,yaw=0}