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();
+}