diff --git a/mods/Skyline_Drive_Mod/main.lua b/mods/Skyline_Drive_Mod/main.lua index 263a0961..4d335cb2 100644 --- a/mods/Skyline_Drive_Mod/main.lua +++ b/mods/Skyline_Drive_Mod/main.lua @@ -64,7 +64,7 @@ local state = { steer=0, shift=0, time=0, enter_after=0, keys={}, buttons=0, hud_time=0, mass_audit=nil, keyboard_axis=0, aero_load=0, enter_requested=false, exit_requested=false, reenter_on_ready=false, - hull_debug=false, interaction_text="", + hull_debug=false, interaction_text="", mp_hint_time=0, } local function clamp(x,a,b) return math.max(a,math.min(b,x)) end @@ -388,6 +388,35 @@ local REMOTE_WHEEL_KEYS={rr="wheel_rr",rl="wheel_rl"} local function vfx_net_active() return sdk.net and type(sdk.net.info)=="function" and (sdk.net.info().active==true) end +local function refresh_mp_status(dt) + if not sdk.net or type(sdk.net.info)~="function" then + sdk.ui.text("skyline_mp","") + return + end + local info=sdk.net.info() + if not info or info.active~=true then + sdk.ui.text("skyline_mp","") + return + end + state.mp_hint_time=math.max(0,(state.mp_hint_time or 0)-dt) + if state.mp_hint_time>0 then return end + state.mp_hint_time=0.5 + local lines={ + state.spawned + and "Your car is spawned and should replicate to other players." + or "Press F10 to spawn your car. Other players cannot see your vehicle until you do.", + "Both players need Skyline enabled with identical mod files and the skyline-driving-update build.", + } + if vfx.supported then + lines[#lines+1]=vfx.mesh + and "VFX buffers ready (remote smoke/skids work without spawning your own car)." + or "VFX supported but buffers not ready yet." + else + lines[#lines+1]="VFX unsupported: update to a build with mesh_buffer (graphics API 4)." + end + if info.status and info.status~="" then lines[#lines+1]=info.status end + sdk.ui.text("skyline_mp",table.concat(lines,"\n")) +end local function clear_mesh(mesh) mesh.positions={};mesh.normals={};mesh.colors={};mesh.uvs={};mesh.indices={} end @@ -995,11 +1024,28 @@ local function tick_remote_vfx(dt) end maybe_upload_skids() end -local function prepare_effects() +local function refresh_vfx_support() vfx.supported=sdk.graphics and (sdk.graphics.version or 0)>=4 and type(sdk.graphics.mesh_buffer)=="function" and type(sdk.graphics.mesh_buffer_append)=="function" - if not vfx.supported then return end + return vfx.supported +end +local function ensure_vfx_mesh() + if not refresh_vfx_support() then return false end + if vfx.mesh then return true end + vfx.smoke=vfx.smoke or {} + vfx.remote_smoke=vfx.remote_smoke or {} + vfx.remote_wheels=vfx.remote_wheels or {} + vfx.mesh={ + smoke={positions={},normals={},colors={},uvs={},indices={}}, + remote_smoke={positions={},normals={},colors={},uvs={},indices={}}, + } + sdk.graphics.mesh_buffer("vfx_smoke",SMOKE_BUFFER_OPTS) + sdk.graphics.mesh_buffer("vfx_smoke_remote",SMOKE_BUFFER_OPTS) + return true +end +local function prepare_effects() + if not refresh_vfx_support() then return end clear_session_skids() vfx.skid_buffer_serial=0 vfx.smoke={}; vfx.smoke_mesh_live=false; vfx.smoke_dirty=false @@ -1048,7 +1094,7 @@ local function tick_wheel_vfx(contacts,dt) publish_net_vfx(contacts) end local function upload_wheel_vfx(event) - if not vfx.supported or not vfx.mesh then return end + if not ensure_vfx_mesh() then return end if sdk.snapshot.paused or sdk.snapshot.replay then return end local dt=clamp(number(event and event.dt,1/60),0,0.1) tick_remote_vfx(dt) @@ -1777,13 +1823,14 @@ return { if not sdk.graphics or (sdk.graphics.version or 0)<2 or not sdk.player.detaching then error("Skyline 4.3.1: the embedded Lua SDK wrapper does not match the native Model Collision Repair API") end - reset_simulation(); remove_rig(); prepare_audio(); prepare_presentation() + reset_simulation(); remove_rig(); prepare_audio(); prepare_presentation(); ensure_vfx_mesh() set_collision_debug(sdk.settings.show_collision_hull==true) sdk.log("Skyline 4.3.1: render-model compound collision, native impacts, animated wheels, safe exits; J toggles actual colliders") sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","") end, on_update=function(event) update_audio(event); update_dashboard(event); upload_wheel_vfx(event) + refresh_mp_status(clamp(number(event and event.dt,1/60),0,0.1)) end, on_settings=function(event) if event.key=="audio_enabled" and event.value==false then stop_audio() end @@ -1800,6 +1847,7 @@ return { clear_session_skids() sdk.ui.text("skyline_audio",""); sdk.ui.text("skyline_presentation","") sdk.ui.text("skyline_help",""); sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","") + sdk.ui.text("skyline_mp","") end, on_fixed_update=function(event) local dt=number(event and event.dt,1/120) @@ -1854,13 +1902,14 @@ return { end, on_event=function(event) if event.name=="world_changed" then - stop_audio(); clear_presentation(); clear_effects(); clear_session_skids(); sound.prepared=false; prepare_audio() - state.spawned=false; state.occupied=false; state.pending=nil + remove_rig(); stop_audio(); clear_presentation(); sound.prepared=false; prepare_audio() + state.pending=nil state.enter_requested=false; state.exit_requested=false; state.reenter_on_ready=false state.enter_after=state.time+C.reentry_delay; interaction_text("") set_collision_debug(state.hull_debug) state.keys={}; state.buttons=0; reset_simulation() sdk.ui.text("skyline_status",""); sdk.ui.text("skyline_handling",""); sdk.ui.text("skyline_wheels","") + sdk.ui.text("skyline_mp","") end end, } diff --git a/tools/test_updater.py b/tools/test_updater.py index 432e8742..f0d47d68 100644 --- a/tools/test_updater.py +++ b/tools/test_updater.py @@ -150,6 +150,13 @@ class UpdaterTests(unittest.TestCase): redirected = u.DownloadRedirect().redirect_request(req, None, 302, 'Found', {}, 'https://release-assets.githubusercontent.com/file') self.assertIsNone(redirected.get_header('Authorization')) + def test_branch_choice_fallback(self): + with patch.object(u, 'list_branches', side_effect=OSError('offline')): + names = u.branch_choices(threading.Event(), 'feature/foo') + self.assertEqual(names[0], 'feature/foo') + self.assertIn('skyline-driving-update', names) + self.assertIn('main', names) + def test_github_api_token_on_actions_requests(self): with patch.dict(os.environ, {'SKATE_UPDATE_GITHUB_TOKEN': 'test-token'}, clear=False): headers = u.github_request_headers(u.BRANCHES_API) @@ -198,51 +205,54 @@ class UpdaterTests(unittest.TestCase): u.stage(candidate, Path(temp), threading.Event(), lambda _: None) self.assertEqual((Path(temp) / 'new/skate3rust.exe').read_bytes(), b'new') - def test_branch_discovery_and_stage(self): - meta = {**metadata(9), 'tag': 'experimental', 'revision': 'b' * 40} - meta['files'] = {name: hashlib.sha256(b'new').hexdigest() for name in u.FILES[:-1]} - package = u.package_name(meta) - archive = io.BytesIO() - with zipfile.ZipFile(archive, 'w') as inner: - for name in [*meta['files'], 'release.json']: - inner.writestr(u.PREFIX + name, json.dumps(meta) if name == 'release.json' else b'new') - package_bytes = archive.getvalue() - digest = hashlib.sha256(package_bytes).hexdigest() - artifact = io.BytesIO() - with zipfile.ZipFile(artifact, 'w') as outer: - outer.writestr(package, package_bytes) - outer.writestr(package + '.sha256', f'{digest} {package}') - outer.writestr('release.json', json.dumps(meta)) - artifact_bytes = artifact.getvalue() + def test_branch_release_status_messages(self): + meta = {**metadata(12), 'tag': 'skyline-driving-update', 'revision': 'd' * 40} + release = dict( + id=90, + tag_name='skyline-driving-update', + draft=False, + prerelease=True, + assets=[ + dict(name=u.PACKAGE, state='uploaded', browser_download_url='package'), + dict(name=u.PACKAGE + '.sha256', state='uploaded', browser_download_url='checksum'), + dict(name='release.json', state='uploaded', browser_download_url='manifest'), + ], + ) + def fetch(url, *args): + if url.endswith('/tags/skyline-driving-update'): + return json.dumps(release).encode() + if url.endswith('/tags/main'): + raise u.urllib.error.HTTPError(url, 404, 'not found', {}, None) + if url == 'manifest': + return json.dumps(meta).encode() + raise AssertionError(url) + + current = {**metadata(12), 'revision': meta['revision']} + with patch.object(u, 'fetch', fetch): + message, revision = u.branch_release_status(current, 'skyline-driving-update', threading.Event()) + self.assertIn('already installed', message) + self.assertEqual(revision, meta['revision']) + message, revision = u.branch_release_status(current, 'main', threading.Event()) + self.assertIn('no published installer', message.lower()) + self.assertIsNone(revision) + + def test_branch_discovery_without_published_release(self): def fetch(url, *args): if '/releases/tags/' in url: raise u.urllib.error.HTTPError(url, 404, 'not found', {}, None) if url.startswith(u.BRANCHES_API): return json.dumps([{'name': 'main'}, {'name': 'skyline'}]).encode() - if '/workflows/' in url and '/runs?' in url: - return json.dumps({'workflow_runs': [ - {'id': 77, 'conclusion': 'success', 'head_sha': meta['revision']}, - ]}).encode() - if url.endswith('/artifacts'): - return json.dumps({'artifacts': [ - {'name': u.ARTIFACT_NAME, 'archive_download_url': 'https://api.github.com/repos/' + u.REPO + '/actions/artifacts/1/zip'}, - ]}).encode() - if url.endswith('/artifacts/1/zip'): - return artifact_bytes raise AssertionError(url) current = metadata(8) with patch.object(u, 'fetch', fetch): names = u.list_branches(threading.Event()) self.assertIn('skyline', names) - candidate = u.discover_branch(current, 'skyline', threading.Event()) - self.assertIsNotNone(candidate) - self.assertEqual(candidate[0], 9) - with tempfile.TemporaryDirectory() as temp: - u.stage(candidate, Path(temp), threading.Event(), lambda _: None) - self.assertEqual((Path(temp) / 'new/skate3rust.exe').read_bytes(), b'new') - self.assertIsNone(u.discover_branch({**current, 'revision': meta['revision']}, 'skyline', threading.Event())) + self.assertIsNone(u.discover_branch(current, 'skyline', threading.Event())) + message, revision = u.branch_release_status(current, 'skyline', threading.Event()) + self.assertIn('No published release', message) + self.assertIsNone(revision) if __name__ == '__main__': diff --git a/tools/updater.py b/tools/updater.py index 24de3cec..e1cc49a5 100644 --- a/tools/updater.py +++ b/tools/updater.py @@ -27,6 +27,7 @@ FILES = ('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe' 'steam-relay/skate-steam-relay.exe', 'steam-relay/steam_api64.dll', 'release.json') PREFIX = 'skate3rust-windows-x64/' +DEFAULT_BRANCH_CHOICES = ('skyline-driving-update', 'main') class DownloadRedirect(urllib.request.HTTPRedirectHandler): @@ -132,6 +133,8 @@ def fetch(url, cancel, limit, progress=lambda value: None): if error.code in (403, 429): raise ValueError('GitHub rate limit or access restriction; try again later') from error if error.code == 404: + if '/releases/tags/' in url: + raise raise ValueError('Releases are not publicly available, or private-release access is missing') from error raise with response: @@ -170,6 +173,17 @@ def release_candidates(assets, rolling): return [(f'release-{n}.json', f'skate3rust-windows-x64-build-{n}.zip') for n in builds] +def merge_branch_names(names, preferred=''): + values = [name for name in (names or DEFAULT_BRANCH_CHOICES) if isinstance(name, str) and name] + preferred = (preferred or '').strip() + if preferred and preferred not in values: + values.insert(0, preferred) + for fallback in DEFAULT_BRANCH_CHOICES: + if fallback not in values: + values.append(fallback) + return values + + def list_branches(cancel, limit=200): branches = [] for page in range(1, 6): @@ -182,6 +196,13 @@ def list_branches(cancel, limit=200): return branches[:limit] +def branch_choices(cancel, preferred=''): + try: + return merge_branch_names(list_branches(cancel), preferred) + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError, json.JSONDecodeError, OSError): + return merge_branch_names([], preferred) + + def branch_workflow_runs(branch, cancel): from urllib.parse import quote query = ( @@ -215,7 +236,7 @@ def package_from_artifact(artifact_zip, package): return payload, checksum -def discover_branch_release(current, branch, cancel, repair=False): +def branch_release_meta(branch, cancel): from urllib.parse import quote branch = (branch or '').strip() try: @@ -230,18 +251,40 @@ def discover_branch_release(current, branch, cancel, repair=False): return None try: meta = json.loads(fetch(asset_url(assets['release.json']), cancel, 65536)) - build = identity(meta) + identity(meta) program_metadata(meta) - except (ValueError, KeyError): + except (ValueError, KeyError, urllib.error.HTTPError): return None + return release, assets, meta + + +def discover_branch_release(current, branch, cancel, repair=False): + loaded = branch_release_meta(branch, cancel) + if loaded is None: + return None + release, assets, meta = loaded if meta['revision'] == current.get('revision') and not repair: return None notes = dict( tag_name=branch, body=(f'Branch `{branch}`\n' - f'Build {build} · revision {meta.get("revision", "")[:12]}'), + f'Build {meta["build"]} · revision {meta.get("revision", "")[:12]}'), ) - return (build, release['id'], notes, assets, meta) + return (meta['build'], release['id'], notes, assets, meta) + + +def branch_release_status(current, branch, cancel): + loaded = branch_release_meta(branch, cancel) + if loaded is None: + branch = (branch or '').strip() + if branch == 'main': + return ('Branch `main` has no published installer. Use `skyline-driving-update`.', None) + return (f'No published release found for branch `{branch}`.', None) + _, _, meta = loaded + revision = meta.get('revision', '') + if revision and revision == current.get('revision'): + return (f'Branch `{branch}` build {meta["build"]} is already installed ({revision[:12]}).', revision) + return (None, revision) def discover_branch(current, branch, cancel, repair=False): @@ -251,32 +294,6 @@ def discover_branch(current, branch, cancel, repair=False): candidate = discover_branch_release(current, branch, cancel, repair=repair) if candidate is not None: return candidate - current_revision = current.get('revision') - for run in branch_workflow_runs(branch, cancel): - if run.get('conclusion') != 'success': - continue - try: - artifact_zip = download_branch_artifact(run['id'], cancel) - import io - with zipfile.ZipFile(io.BytesIO(artifact_zip)) as archive: - if 'release.json' not in {info.filename for info in archive.infolist()}: - continue - meta = json.loads(archive.read('release.json')) - build = identity(meta) - program_metadata(meta) - if meta['revision'] == current_revision and not repair: - continue - package = package_name(meta) - package_from_artifact(artifact_zip, package) - notes = dict( - tag_name=meta.get('tag', branch), - body=(f'Branch `{branch}`\n' - f'Workflow run {run.get("id")} @ {run.get("head_sha", "")[:12]}\n' - f'Build {build} · revision {meta.get("revision", "")[:12]}'), - ) - return (build, run['id'], notes, None, meta, artifact_zip) - except (ValueError, KeyError, zipfile.BadZipFile): - continue return None @@ -463,8 +480,23 @@ def main(request=None): def branch_ui_enabled(): return channel.get() == 'Branch' + def apply_branch_choices(names): + branch_choice.configure(values=merge_branch_names(names, branch.get().strip())) + def refresh_branch_ui(): - branch_choice.configure(state='normal' if branch_ui_enabled() else 'disabled') + enabled = branch_ui_enabled() + branch_choice.configure(state='normal' if enabled else 'disabled') + if enabled: + apply_branch_choices(branch_choice.cget('values') or DEFAULT_BRANCH_CHOICES) + + def load_branch_list(): + token = generation + def run(): + try: + events.put((token, 'branches', branch_choices(cancel, branch.get().strip()))) + except Exception: + events.put((token, 'branches', merge_branch_names([], branch.get().strip()))) + threading.Thread(target=run, daemon=True).start() def work(kind, operation): nonlocal busy @@ -512,12 +544,12 @@ def main(request=None): repair_after_update = (bool(repair_files) and isinstance(previous.get('build'), int) and previous['build'] < current['build']) if selected == 'Branch': - names = list_branches(cancel) - selected_branch = branch.get().strip() - if selected_branch and selected_branch not in names: - names.insert(0, selected_branch) - return names, discover_branch(current, selected_branch, cancel, repair=bool(repair_files)) - return None, discover(current, selected, cancel, repair=bool(repair_files)) + selected_branch = branch.get().strip() or DEFAULT_BRANCH_CHOICES[0] + names = branch_choices(cancel, selected_branch) + candidate = discover_branch(current, selected_branch, cancel, repair=bool(repair_files)) + branch_status = branch_release_status(current, selected_branch, cancel) if candidate is None else None + return names, candidate, branch_status + return None, discover(current, selected, cancel, repair=bool(repair_files)), None work('checked', perform_check) def do_update(): @@ -552,10 +584,14 @@ def main(request=None): win.protocol('WM_DELETE_WINDOW', close) def channel_changed(_=None): refresh_branch_ui() + if branch_ui_enabled(): + load_branch_list() check_now() choice.bind('<>', channel_changed) refresh_branch_ui() + if branch_ui_enabled(): + load_branch_list() def poll(): nonlocal candidate, busy, automatic @@ -567,6 +603,9 @@ def main(request=None): if kind == 'progress': status.set(value) continue + if kind == 'branches': + apply_branch_choices(value) + continue busy = False if closing: win.destroy() @@ -583,12 +622,9 @@ def main(request=None): win.destroy() return elif kind == 'checked': - branch_names, candidate = value + branch_names, candidate, branch_status = value if branch_names: - values = [name for name in branch_names if isinstance(name, str) and name] - if branch.get().strip() and branch.get().strip() not in values: - values.insert(0, branch.get().strip()) - branch_choice.configure(values=values) + apply_branch_choices(branch_names) if candidate: automatic = False win.deiconify() @@ -611,8 +647,10 @@ def main(request=None): win.destroy() return else: - if channel.get() == 'Branch': - status.set('No successful CI build found for this branch yet, or you already have that revision.') + if channel.get() == 'Branch' and branch_status and branch_status[0]: + status.set(branch_status[0]) + elif channel.get() == 'Branch': + status.set('No published branch release found, or you already have that revision.') elif repair_files: status.set('Repair needed, but this build is not available in the selected channel. Select its channel or a newer release.') else: