const std = @import("std"); const builtin = @import("builtin"); const log = std.log.scoped(.mcl); /// Wrapper over http client impl /// I'd love this to be the zig stdlig one but it doesn't support TLSv1.2 which /// mojang are using (and not supporting v1.3 :( ). const HttpClient = struct { const curl = @cImport(@cInclude("curl/curl.h")); allocator: std.mem.Allocator, c_curl: *curl.CURL, errbuf: [curl.CURL_ERROR_SIZE]u8 = [_]u8{0}**curl.CURL_ERROR_SIZE, fn init(allocator: std.mem.Allocator) !HttpClient { const c_curl = curl.curl_easy_init() orelse return error.CurlInitFailed; return .{ .allocator = allocator, .c_curl = c_curl, }; } fn deinit(self: *HttpClient) void { curl.curl_easy_cleanup(self.c_curl); } const Request = struct { url: []const u8, }; const Response = struct { response_buf: std.ArrayList(u8), did_oom: bool = false, status: std.http.Status = .teapot, fn init(allocator: std.mem.Allocator) Response { return .{ .response_buf = std.ArrayList(u8).init(allocator), }; } fn deinit(self: *Response) void { self.response_buf.deinit(); } // size_t write_data(char *buffer, size_t size, size_t nmemb, void *userp); // https://curl.se/libcurl/c/CURLOPT_WRITEDATA.html fn write_data(buffer: [*c]u8, _: usize, nmemb: usize, userp: *anyopaque) callconv(.C) usize { var self: *Response = @ptrCast(@alignCast(userp)); self.response_buf.appendSlice(buffer[0..nmemb]) catch { self.did_oom = true; return 0; }; return nmemb; } }; fn do(self: *HttpClient, request: Request) !Response { const urlz = try self.allocator.dupeZ(u8, request.url); defer self.allocator.free(urlz); var response = Response.init(self.allocator); errdefer response.deinit(); if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_ERRORBUFFER, &self.errbuf) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_URL, urlz.ptr) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_WRITEFUNCTION, Response.write_data) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_WRITEDATA, &response) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_perform(self.c_curl) != curl.CURLE_OK) { if (response.did_oom) { log.err("OOM receiving data from cURL", .{}); return error.OutOfMemory; } else { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } } return response; } }; /// Minecraft launcher! pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{ .safety = false }){}; // TODO turn safety back on defer _ = gpa.deinit(); const allocator = gpa.allocator(); var client = try HttpClient.init(allocator); defer client.deinit(); log.info("downloading manifest from {s}", .{Manifest.uri}); const manifest_wrap = try download_json(&client, Manifest, Manifest.uri); defer manifest_wrap.deinit(); const manifest = manifest_wrap.value; log.info("release version: {s}", .{ manifest.latest.release }); // Find the release version const release_version_url = for (manifest.versions) |v| { if (std.mem.eql(u8, v.id, manifest.latest.release)) { break v.url; } } else { return error.NoReleaseVersionFound; }; log.info("downloading release manifest from {s}", .{release_version_url}); var release_version_wrap = try download_json(&client, Version, release_version_url); defer release_version_wrap.deinit(); const release_version: Version = release_version_wrap.value; // Download all the required files var root_dir_path = try get_minecraft_root_dir(allocator); defer allocator.free(root_dir_path); // try download_version(allocator, &client, root_dir_path, release_version); try play_version(allocator, root_dir_path, release_version); } fn play_version(allocator: std.mem.Allocator, root_dir_path: []const u8, version: Version) !void { // version dir const dir_path = try version.dir_path(allocator, root_dir_path); defer allocator.free(dir_path); var dir = try std.fs.openDirAbsolute(dir_path, .{}); defer dir.close(); var cmd_buf = std.ArrayList([]const u8).init(allocator); try cmd_buf.append("java"); try cmd_buf.append("-cp"); try cmd_buf.append("*"); // try cmd_buf.append(version.mainClass); try cmd_buf.append("net.minecraft.client.main.Main"); try cmd_buf.append("-accessToken=foo"); try cmd_buf.append("-version=1.20.1"); const cmd = try cmd_buf.toOwnedSlice(); defer allocator.free(cmd); const cmd_log = try std.mem.join(allocator, " ", cmd); defer allocator.free(cmd_log); log.info("running minecraft cmd [{s}] in cwd {s}", .{cmd_log, dir_path}); var proc = std.ChildProcess.init(cmd, allocator); proc.stdin_behavior = .Ignore; proc.cwd = dir_path; proc.cwd_dir = null; proc.env_map = null; proc.expand_arg0 = .no_expand; _ = try proc.spawnAndWait(); } fn download_version(allocator: std.mem.Allocator, client: *HttpClient, root_dir_path: []const u8, version: Version) !void { const dir_path = try version.dir_path(allocator, root_dir_path); defer allocator.free(dir_path); std.fs.makeDirAbsolute(dir_path) catch |e| switch (e) { error.PathAlreadyExists => {}, // fine, swallow the error else => return e, }; log.info("downloading version {s} to {s}", .{version.id, dir_path}); const dir = try std.fs.openDirAbsolute(dir_path, .{}); var client_jar = try dir.createFile("client.jar", .{}); defer client_jar.close(); try download_file(client, &client_jar, version.downloads.client.url); var client_map = try dir.createFile("client.map", .{}); defer client_map.close(); try download_file(client, &client_map, version.downloads.client_mappings.url); // TODO server // Dependencies... lib: for (version.libraries) |library| { if (library.rules) |rules| for (rules) |rule| { // TODO rule.features if (rule.os) |os| { if (os.name) |osname| { if (osname == .windows and builtin.os.tag != .windows) { continue :lib; } else if (osname == .osx and builtin.os.tag != .macos) { continue :lib; } else if (osname == .linux and builtin.os.tag != .linux) { continue :lib; } } if (os.arch) |osarch| { if (osarch == .x86 and builtin.cpu.arch != .x86) { continue :lib; } } } }; const file_path = std.fs.path.basename(library.downloads.artifact.path); var file = try dir.createFile(file_path, .{}); defer file.close(); try download_file(client, &file, library.downloads.artifact.url); } } // Caller frees the result fn fix_https(allocator: std.mem.Allocator, url_maybe_https: []const u8) ![]const u8 { // This particular URL doesn't support TLSv1.3 :( // and zig doesn't support TLSv1.2. Booooo. Hack it and use http for now. if (std.mem.indexOf(u8, url_maybe_https, "piston-meta.mojang.com") != null or std.mem.indexOf(u8, url_maybe_https, "piston-data.mojang.com") != null) { const sz = std.mem.replacementSize(u8, url_maybe_https, "https://", "http://"); const url = try allocator.alloc(u8, sz); _ = std.mem.replace(u8, url_maybe_https, "https://", "http://", url); return url; } else { return try allocator.dupe(u8, url_maybe_https); } } fn download_file(client: *HttpClient, file: *std.fs.File, url: []const u8) !void { log.info("downloading file {s}", .{url}); const a = client.allocator; var r = try client.do(.{.url= url}); defer r.deinit(); const sl = try r.response_buf.toOwnedSlice(); defer a.free(sl); try file.writeAll(sl); } /// https://minecraft.fandom.com/wiki/Version_manifest.json const Manifest = struct { const uri = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; latest: struct { release: []const u8, snapshot: []const u8, }, versions: []struct { id: []const u8, type: []const u8, url: []const u8, releaseTime: []const u8, sha1: []const u8, complianceLevel: i64, }, }; /// https://minecraft.fandom.com/wiki/Client.json const Version = struct { const Rule = struct { const Feature = struct { is_demo_user: bool = false, has_custom_resolution: bool = false, is_quick_play_multiplayer: bool = false, is_quick_play_realms: bool = false, }; const Os = struct { name: ?enum{osx,linux,windows} = null, arch: ?enum{x86} = null, }; action: enum{allow}, features: ?Feature = null, os: ?Os = null, }; const Arg = union(enum) { const Extended = struct { const Value = union(enum) { single: []const u8, many: []const []const u8, pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !Value { switch (try source.peekNextTokenType()) { .array_begin => { return .{ .many = try std.json.innerParse([]const []const u8, allocator, source, options) }; }, .string => { return .{ .single = try std.json.innerParse([]const u8, allocator, source, options) }; }, else => return error.UnexpectedToken, } } }; rules: []Rule, value: Value, }; plain: []const u8, extended: Extended, pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !Arg { switch (try source.peekNextTokenType()) { .object_begin => { return .{ .extended = try std.json.innerParse(Extended, allocator, source, options), }; }, .string => { return .{ .plain = try std.json.innerParse([]const u8, allocator, source, options), }; }, else => return error.UnexpectedToken, } } }; const Download = struct { sha1: []const u8, size: u64, url: []const u8, }; const Library = struct { downloads: struct { artifact: struct { path: []const u8, sha1: []const u8, size: u64, url: []const u8, }, }, name: []const u8, rules: ?[]Rule = null, }; arguments: struct { game: []Arg, jvm: []Arg, }, assetIndex: struct { id:[]const u8, sha1: []const u8, size: u64, totalSize: u64, url: []const u8, }, assets: []const u8, complianceLevel: u8, downloads: struct { client: Download, client_mappings: Download, server: Download, server_mappings: Download, }, id: []const u8, javaVersion: struct { component: []const u8, majorVersion: u8, }, libraries: []Library, logging: struct { client: struct { argument: []const u8, file: struct { id: []const u8, sha1: []const u8, size: u64, url: []const u8, }, type: []const u8, }, }, mainClass: []const u8, minimumLauncherVersion: u8, releaseTime: []const u8, time: []const u8, type: []const u8, /// Caller must free the result fn dir_path(self: Version, allocator: std.mem.Allocator, root_dir_path: []const u8) ![]const u8 { return try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "versions", self.id}); } }; /// Caller is responsible for calling deinit on the resulting Parsed struct. fn download_json(client: *HttpClient, comptime T: type, url: []const u8) !std.json.Parsed(T) { const a = client.allocator; var r = try client.do(.{.url = url}); var sl = try r.response_buf.toOwnedSlice(); defer a.free(sl); // TODO check status // if (r.response.status != .ok) { // log.err("request failed {}:{s}", .{ r.response.status, r.response.reason }); // return error.HttpStatusError; // } var f = std.io.fixedBufferStream(sl); var r2 = std.json.reader(a, f.reader()); var diags = std.json.Diagnostics{}; r2.enableDiagnostics(&diags); defer r2.deinit(); var res = std.json.parseFromTokenSource(T, a, &r2, .{ .ignore_unknown_fields = true, .allocate = .alloc_always }) catch |e| { log.err("parse failed {} line {} col {}", .{ e, diags.getLine(), diags.getColumn() }); return e; }; return res; } /// Root of minecraft data directory. /// Calling ensures the directory exists. /// Caller owns the result and must free it fn get_minecraft_root_dir(allocator: std.mem.Allocator) ![]const u8 { const home_dir = std.os.getenv("HOME") orelse { return error.AppDataDirUnavailable; }; const root_dir_path = try std.fs.path.join(allocator, &[_][]const u8{home_dir, ".minecraft"}); _ = std.fs.openDirAbsolute(root_dir_path, .{}) catch |e| switch (e) { error.FileNotFound => { try std.fs.makeDirAbsolute(root_dir_path); }, else => return e, }; return root_dir_path; } test "heterogenous array" { const x = \\ { \\ "y": [ \\ { "z": "yay" }, \\ "nay" \\ ] \\ } ; const IX = union(enum) { // What to name union tags? const P = struct { z: []const u8 }; p: P, q: []const u8, pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { switch (try source.peekNextTokenType()) { .object_begin => { return .{ .p = try std.json.innerParse(P, allocator, source, options), }; }, .string => { return .{ .q = try std.json.innerParse([]const u8, allocator, source, options), }; }, else => return error.UnexpectedToken, } } }; const X = struct { y: []const IX }; const xx = try std.json.parseFromSlice(X, std.testing.allocator, x, .{}); defer xx.deinit(); try std.testing.expectEqualDeep(X{ .y = &[_]IX{ .{ .p = .{ .z = "yay" } }, .{ .q = "nay" }, }, }, xx.value); } test "string list" { const x = \\["ond", "two", "three", "four"] ; const v_w = try std.json.parseFromSlice([]const []const u8, std.testing.allocator, x, .{}); defer v_w.deinit(); const expect: []const []const u8 = &[_][]const u8{ "ond", "two", "three", "four" }; try std.testing.expectEqualDeep(expect, v_w.value); } test "string or string list" { const x1 = \\ "testing" ; const x2 = \\ ["one", "two"] ; const X = union(enum) { one: []const u8, many: []const []const u8, pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { switch (try source.peekNextTokenType()) { .array_begin => { return .{ .many = try std.json.innerParse([]const []const u8, allocator, source, options) }; }, .string => { return .{ .one = try std.json.innerParse([]const u8, allocator, source, options) }; }, else => return error.UnexpectedToken, } } }; var ax1_w = try std.json.parseFromSlice(X, std.testing.allocator, x1, .{}); defer ax1_w.deinit(); var ax2_w = try std.json.parseFromSlice(X, std.testing.allocator, x2, .{}); defer ax2_w.deinit(); } test "Version" { const x = @embedFile("version_test.json"); var f = std.io.fixedBufferStream(x); var jr = std.json.reader(std.testing.allocator, f.reader()); defer jr.deinit(); var d = std.json.Diagnostics{}; jr.enableDiagnostics(&d); var v_w = std.json.parseFromTokenSource(Version, std.testing.allocator, &jr, .{.ignore_unknown_fields = true}) catch |e| { log.err("parse failed {} line {} col {}", .{ e, d.getLine(), d.getColumn() }); return e; }; defer v_w.deinit(); const v = v_w.value; _ = v; } test "Manifest" { const x = @embedFile("manifest_test.json"); var f = std.io.fixedBufferStream(x); var jr = std.json.reader(std.testing.allocator, f.reader()); defer jr.deinit(); var d = std.json.Diagnostics{}; jr.enableDiagnostics(&d); var v_w = std.json.parseFromTokenSource(Manifest, std.testing.allocator, &jr, .{.ignore_unknown_fields = true}) catch |e| { log.err("parse failed {} line {} col {}", .{ e, d.getLine(), d.getColumn() }); return e; }; defer v_w.deinit(); const v = v_w.value; _ = v; }