diff --git a/Directory.Build.props b/Directory.Build.props index 5d231e3..c732911 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -32,4 +32,18 @@ SPDX-License-Identifier: GPL-2.0-or-later none false + + + + + + + + + + + + diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 0d43b6d..14229b3 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -76,6 +76,8 @@ internal static partial class Program SharpEmuLog.MinimumLevel = logLevel; + Log.Info(BuildInfo.Banner); + ebootPath = Path.GetFullPath(ebootPath); Console.Error.WriteLine($"[DEBUG] Full path: {ebootPath}"); diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs index a0fd5fc..ebc4b36 100644 --- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs +++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs @@ -16,6 +16,7 @@ using System.Buffers.Binary; using System.Collections.Generic; using System.Linq; using System.Text; +using System.IO; namespace SharpEmu.Core.Runtime; @@ -141,6 +142,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime var image = LoadImage(normalizedEbootPath); VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version); SaveDataExports.ConfigureApplicationInfo(image.TitleId); + LogAppBundleInfo(normalizedEbootPath, image); RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false); KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress); Log.Info($"Entry: 0x{image.EntryPoint:X16}"); @@ -357,6 +359,66 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime return result; } + private static void LogAppBundleInfo(string ebootPath, SelfImage image) + { + var executableName = Path.GetFileName(ebootPath); + if (string.IsNullOrWhiteSpace(executableName)) + { + executableName = "eboot.bin"; + } + + var displayName = string.IsNullOrWhiteSpace(image.Title) ? "(unknown)" : image.Title!.Trim(); + var titleId = string.IsNullOrWhiteSpace(image.TitleId) ? "(unknown)" : image.TitleId!.Trim(); + var version = string.IsNullOrWhiteSpace(image.Version) ? "(unknown)" : image.Version!.Trim(); + var contentId = ResolveContentId(Path.GetDirectoryName(ebootPath)) ?? "(unknown)"; + + var builder = new StringBuilder(); + builder.AppendLine("App bundle info:"); + builder.AppendLine($"- Display name: {displayName}"); + builder.AppendLine($"- Version: {version}"); + builder.AppendLine($"- Title ID: {titleId}"); + builder.AppendLine($"- Content ID: {contentId}"); + builder.AppendLine($"- Executable: {executableName}"); + builder.Append("- Platform: PlayStation 5"); + Log.Info(builder.ToString()); + } + + private static string? ResolveContentId(string? bundleRoot) + { + if (string.IsNullOrEmpty(bundleRoot)) + { + return null; + } + + foreach (var candidate in new[] + { + Path.Combine(bundleRoot, "sce_sys", "param.json"), + Path.Combine(bundleRoot, "param.json"), + }) + { + if (!File.Exists(candidate)) + { + continue; + } + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllBytes(candidate)); + if (doc.RootElement.TryGetProperty("contentId", out var contentId)) + { + return contentId.GetString(); + } + } + catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException) + { + } + + break; + } + + return null; + } + private static App0BindingScope? BindApp0Root(string normalizedEbootPath) { const string app0VariableName = "SHARPEMU_APP0_DIR"; diff --git a/src/SharpEmu.Logging/BuildInfo.cs b/src/SharpEmu.Logging/BuildInfo.cs new file mode 100644 index 0000000..fc5fd22 --- /dev/null +++ b/src/SharpEmu.Logging/BuildInfo.cs @@ -0,0 +1,144 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Reflection; + +namespace SharpEmu.Logging; + +/// +/// Build provenance for the running emulator, populated at compile time from +/// GitHub Actions environment variables via [AssemblyMetadata] and +/// surfaced as a touchHLE-style banner at the top of the log. +/// +public static class BuildInfo +{ + private const string ProjectUrl = "https://github.com/par274/sharpemu"; + private const string CanonicalRepository = "par274/sharpemu"; + + /// Short commit hash the build was produced from, or null. + public static string? CommitSha { get; } + + /// Branch the build was produced from, or null. + public static string? Branch { get; } + + /// Repository slug (owner/name) the build came from, or null. + public static string? Repository { get; } + + /// URL of the GitHub Actions workflow run that produced the build, or null. + public static string? WorkflowRunUrl { get; } + + /// Build configuration (e.g. Debug or Release), or null. + public static string? Configuration { get; } + + /// + /// Whether this build is an official release: a Release-configuration CI build + /// from the canonical repository, produced by a push to main or a manual + /// workflow dispatch (matching the CI release job). All other builds + /// (pull requests, forks, feature branches, and local/Debug builds) are + /// considered unofficial. + /// + public static bool IsOfficialRelease { get; } + + static BuildInfo() + { + var metadata = ReadMetadata(); + + CommitSha = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildSha")); + if (CommitSha is { Length: > 7 }) + { + CommitSha = CommitSha[..7]; + } + + Branch = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildBranch")); + Repository = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildRepository")); + Configuration = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildConfiguration")); + + var serverUrl = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildServerUrl")); + var runId = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildRunId")); + if (serverUrl is not null && Repository is not null && runId is not null) + { + WorkflowRunUrl = $"{serverUrl.TrimEnd('/')}/{Repository}/actions/runs/{runId}"; + } + + var eventName = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildEventName")); + var gitRef = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildRef")); + + var isReleaseConfig = string.Equals(Configuration, "Release", StringComparison.OrdinalIgnoreCase); + var isCanonicalRepo = string.Equals(Repository, CanonicalRepository, StringComparison.OrdinalIgnoreCase); + var isReleaseTrigger = + string.Equals(eventName, "workflow_dispatch", StringComparison.OrdinalIgnoreCase) || + (string.Equals(eventName, "push", StringComparison.OrdinalIgnoreCase) && + string.Equals(gitRef, "refs/heads/main", StringComparison.OrdinalIgnoreCase)); + + IsOfficialRelease = isReleaseConfig && isCanonicalRepo && isReleaseTrigger && CommitSha is not null; + } + + /// + /// The multi-line banner, e.g. + /// + /// SharpEmu UNOFFICIAL f11ac59 — https://github.com/par274/sharpemu + /// + /// Built from branch "main" of "par274/sharpemu" by GitHub Actions workflow run https://github.com/par274/sharpemu/actions/runs/123. + /// + /// Official release builds drop the UNOFFICIAL tag. Falls back to a + /// local-build line when no CI provenance is present. + /// + public static string Banner + { + get + { + string version; + if (CommitSha is null) + { + version = "UNOFFICIAL"; + } + else if (IsOfficialRelease) + { + version = CommitSha; + } + else + { + version = $"UNOFFICIAL {CommitSha}"; + } + + var header = $"SharpEmu {version} — {ProjectUrl}"; + + if (Branch is null || Repository is null || WorkflowRunUrl is null) + { + return $"{header}{Environment.NewLine}{Environment.NewLine}Local build (not produced by CI)."; + } + + return $"{header}{Environment.NewLine}{Environment.NewLine}" + + $"Built from branch \"{Branch}\" of \"{Repository}\" by GitHub Actions workflow run {WorkflowRunUrl}."; + } + } + + private static Dictionary ReadMetadata() + { + var result = new Dictionary(StringComparer.Ordinal); + var assemblies = new[] { Assembly.GetEntryAssembly(), typeof(BuildInfo).Assembly }; + + foreach (var assembly in assemblies) + { + if (assembly is null) + { + continue; + } + + foreach (var attribute in assembly.GetCustomAttributes()) + { + if (attribute.Key.StartsWith("SharpEmu.Build", StringComparison.Ordinal) && + !result.ContainsKey(attribute.Key) && + attribute.Value is not null) + { + result[attribute.Key] = attribute.Value; + } + } + } + + return result; + } + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +}