Obtaining Medal Records
Note
The guide below is only applicable to Halo Infinite.
With the latest release of Orion I've added the ability to obtain medals and medal-related metadata for Halo Infinite. This is extremely helpful for scenarios where you, the developer, want to build an experience that enables visibility into medal breakdowns - how many of them, what kinds of medals, what the rarity of them is, and so on. In this article I am outlining an approach that you can apply to get medal data without worrying about individual REST API calls to the Halo Infinite API.
Prerequisites
The only pre-requisite you need is general familiarity with the Orion authentication flow (going from zero to a logged in user). Requests for medal information require access to the Spartan token, which can be obtained by following a few steps built into Orion. You can familiarize yourself with this information by following the Getting Started guide.
Theory
Let's first start by walking through the theory of medal acquisition. As you play the game you will eventually start earning medals for in-game actions - those will be highlighted directly on the screen as you get them.
Those medals are tracked by the Halo Infinite service, and you can even visualize them through the Halo Waypoint website and app.
Halo Infinite API enables us to get the list of available medals (that is, available on the service side) by acquiring the medals/metadata.json
file from the game content management system (CMS), available through the following endpoint:
https://gamecms-hacs.svc.halowaypoint.com:443/hi/Waypoint/file/medals/metadata.json
The response for this would contain all the information you need to understand the existing medals in Halo Infinite. The format of the output is similar to the snippet below:
{
"difficulties": [
"normal",
"heroic",
"legendary",
"mythic"
],
"types": [
"spree",
"mode",
"multikill",
"proficiency",
"skill",
"style"
],
"sprites": {
"small": {
"path": "medals/images/medal_sheet_sm.png",
"columns": 16,
"size": 72
},
"medium": {
"path": "medals/images/medal_sheet_med.png",
"columns": 16,
"size": 128
},
"extra-large": {
"path": "medals/images/medal_sheet_xl.png",
"columns": 16,
"size": 256
}
},
"medals": [
{
"name": {
"value": "Double Kill",
"translations": {
"pt-BR": "Abate Duplo",
"zh-CN": "双连击",
"zh-TW": "雙殺",
"de-DE": "Doppelter Abschuss",
"fr-FR": "Double frag",
"it-IT": "Doppia uccisione",
"ja-JP": "ダブル キル",
"ko-KR": "두 명 잡음",
"es-MX": "Doble muerte",
"nl-NL": "Dubbele kill",
"pl-PL": "Podwójne zabójstwo",
"ru-RU": "Двойное убийство",
"es-ES": "Doble muerte"
}
},
"description": {
"value": "Kill 2 enemies in quick succession",
"translations": {
"pt-BR": "Mate 2 inimigos em sequência",
"zh-CN": "快速连续击杀 2 名敌人",
"zh-TW": "快速連續擊殺 2 個敵人",
"de-DE": "2 Gegner schnell hintereinander eliminieren",
"fr-FR": "Tuez 2 ennemis d'affilée.",
"it-IT": "Uccidi 2 nemici in rapida successione",
"ja-JP": "連続で 2 体の敵を倒す",
"ko-KR": "빠르게 연속해서 2명의 적을 처치하십시오",
"es-MX": "Abate 2 enemigos en sucesión rápida",
"nl-NL": "Dood snel achter elkaar 2 vijanden",
"pl-PL": "Zabij 2 przeciwników w krótkich odstępach czasu.",
"ru-RU": "Быстро убейте 2 врагов.",
"es-ES": "Mata a 2 enemigos en una rápida sucesión."
}
},
"spriteIndex": 64,
"sortingWeight": 100,
"difficultyIndex": 1,
"typeIndex": 2,
"personalScore": 50,
"nameId": 622331684
},
{
"name": {
"value": "Triple Kill",
"translations": {
"pt-BR": "Abate Triplo",
"zh-CN": "三连击",
"zh-TW": "三殺",
"de-DE": "Dreifacher Abschuss",
"fr-FR": "Triple frag",
"it-IT": "Tripla uccisione",
"ja-JP": "トリプル キル",
"ko-KR": "세 명 잡음",
"es-MX": "Triple muerte",
"nl-NL": "Driedubbele kill",
"pl-PL": "Potrójne zabójstwo",
"ru-RU": "Тройное убийство",
"es-ES": "Triple muerte"
}
},
"description": {
"value": "Kill 3 enemies in quick succession",
"translations": {
"pt-BR": "Mate 3 inimigos em sequência",
"zh-CN": "快速连续击杀 3 名敌人",
"zh-TW": "快速連續擊殺 3 個敵人",
"de-DE": "3 Gegner schnell hintereinander eliminieren",
"fr-FR": "Tuez 3 ennemis d'affilée.",
"it-IT": "Uccidi 3 nemici in rapida successione",
"ja-JP": "連続で 3 体の敵を倒す",
"ko-KR": "빠르게 연속해서 3명의 적을 처치하십시오",
"es-MX": "Abate 3 enemigos en sucesión rápida",
"nl-NL": "Dood snel achter elkaar 3 vijanden",
"pl-PL": "Zabij 3 przeciwników w krótkich odstępach czasu.",
"ru-RU": "Быстро убейте 3 врагов.",
"es-ES": "Mata a 3 enemigos en una rápida sucesión."
}
},
"spriteIndex": 65,
"sortingWeight": 150,
"difficultyIndex": 2,
"typeIndex": 2,
"personalScore": 100,
"nameId": 2063152177
},
[...]
Few important points worth calling out here about medals in the list:
- Every medal has an associated difficulty.
- Every medal has a type/class.
- Every medal is captured inside a spritesheet that is available in three sizes: small (every medal is 72px x 72px), large (every medal is 128px x 128px), and extra large (every medal is 256px x 256px).
- For every medal entity there is a name and a description that are available as a default (English) and translated strings.
- Every medal entity has a sprite index that allows the discovery within the sprite sheet.
- Every medal has a sorting weight that defines where in the list of medals it will be displayed.
- Every medal has a personal score associated with it which, interestingly enough, is not currently used in-game.
- Medals are identified by their
nameId
property.
With these fundamentals out of the way, let's take a look at what a medal sprite sheet looks like (yes, there is a lot of empty space - the medals are scattered around).
The number of columns also happens to be declared in the medal metadata above which, combined with knowing the pixel size (width and height) of each medal makes it possible for us to extract individual medals from the sheet. Because the sheet is a transparent PNG, we get neat representations of each medal without much fuss. However, one big missing piece above is the information about medals awarded for a specific player. That is, we're not only interested in getting the information about the medals in the game but also the information about medals that a player has earned.
To get that information we need to call the halostats
endpoint to get the player service record. For example, here is what that would look like for my account:
https://halostats.svc.halowaypoint.com:443/hi/players/zebond/Matchmade/servicerecord?seasonId=Seasons/Season7.json
There's another interesting thing that I want to mention above - when I am getting the service record, it's possible to pass a season ID if I only want to get the service record for a season. Alternatively, that parameter can be skipped altogether and the output of it would be a full list of medals across all seasons in which a player has participated.
The output of the call to the REST endpoint above would be something like this:
{
"Subqueries": {
"SeasonIds": null,
"GameVariantCategories": [
7,
9,
18,
12,
6,
39,
19,
14,
11,
15
],
"IsRanked": null,
"PlaylistAssetIds": [
"0299adc1-f07a-4b6c-8126-0c35ac2fa08d",
"4829f027-a9af-4b2f-86dd-7b290d6bb0a4",
"edfef3ac-9cbe-4fa2-b949-8f29deafd483",
"70bb9184-e674-4307-8846-239ab4a30cb6",
"aa41f6a9-51be-4f25-a53f-48192ce14de7",
"bdceefb3-1c52-4848-a6b7-d49acd13109d",
"7d9828c7-8184-4421-ad14-824fce8f7ebe",
"3facc347-6e49-40c9-b9e7-503d26092eed",
"dc4929de-216c-43bc-b207-1702253f4576",
"14352c39-a409-4d34-9ded-1c280bb4f868",
"325c18a5-d85b-4ba6-b98f-21465d9c19e2",
"73b48e1e-05c4-4004-927d-965549b28396",
"7de5ed5b-381e-49e5-b334-d959056dbc2b",
"fa5aa2a3-2428-4912-a023-e1eeea7b877c"
]
},
"TimePlayed": "P5DT3H48M38.5998599S",
"MatchesCompleted": 859,
"Wins": 354,
"Losses": 464,
"Ties": 4,
"CoreStats": {
"Score": 9641,
"PersonalScore": 1296745,
"RoundsWon": 383,
"RoundsLost": 498,
"RoundsTied": 9,
"Kills": 9942,
"Deaths": 10693,
"Assists": 2773,
"AverageKDA": 0.2017850213426472,
"Suicides": 64,
"Betrayals": 12,
"GrenadeKills": 1015,
"HeadshotKills": 3216,
"MeleeKills": 2414,
"PowerWeaponKills": 1172,
"ShotsFired": 238579,
"ShotsHit": 98997,
"Accuracy": 41.49443161384699,
"DamageDealt": 2565765,
"DamageTaken": 2652197,
"CalloutAssists": 176,
"VehicleDestroys": 73,
"DriverAssists": 11,
"Hijacks": 9,
"EmpAssists": 1,
"MaxKillingSpree": 9,
"Medals": [
{
"NameId": 2123530881,
"Count": 442,
"TotalPersonalScoreAwarded": 0
},
{
"NameId": 548533137,
"Count": 251,
"TotalPersonalScoreAwarded": 0
},
{
"NameId": 1512363953,
"Count": 95,
"TotalPersonalScoreAwarded": 0
},
{
"NameId": 2625820422,
"Count": 253,
"TotalPersonalScoreAwarded": 0
},
[...]
I'll skip over the generic properties (that's for another documentation article) and instead focus on the medals. Within the Medals
property I will get the comprehensive list of medals awarded for the player during the season or during the entirety of their lifetime within Halo Infinite. Each medal comes with three properties:
NameId
that represents the unique medal ID.Count
, representing the number of medals earned.TotalPersonalScoreAwarded
that is generally 0 across the board since medal-based scores don't seem to be enabled in game as of today.
This now gives us enough to go and start exporting specific medal snapshots - all we do is map NameId
from the service record to nameId
in medals.json
.
Practice
Now that you are familiar with the general breakdown of medal API logic, let's try and put that into practice by using Orion. What I wanted to do is generate an image for myself that would be an effective snapshot of the medals that I've earned through my career in Halo Infinite. To do that, I could use the following:
GameCmsGetMedalMetadata
- this gets us the baseline metadata information about the available medals.GameCmsGetGenericWaypointFile
- this gets us the spritesheet from the Halo Infinite game CMS.StatsGetPlayerServiceRecord
- this function will get us the player service record with earned medal information.
First, let's get the available medal information:
Console.WriteLine("Getting medal metadata...");
var medalReferences = (await client.GameCmsGetMedalMetadata()).Result;
Executing the snippet above will give us a MedalMetadata
instance containing the information we seek about in-game medals. Next, I want to actually get the medal spritesheet that will allow me to generate a nicely-formatted image of all the medals a player earned. To do that, I will use the following snippet:
Console.WriteLine($"Getting the extra large medal sprite sheet at {medalReferences.Sprites.ExtraLarge.Path}...");
var spriteContent = (await client.GameCmsGetGenericWaypointFile(medalReferences.Sprites.ExtraLarge.Path)).Result;
In this context, spriteContent
will be a byte array containing the PNG with the medals. I am picking up the path to the spritesheet from the GameCmsGetMedalMetadata
call.
Because the image generator I am writing should be cross-platform, I decided to use SkiaSharp and .NET MAUI for image processing. With the packages installed, I needed to add two references to the file header:
using Microsoft.Maui.Graphics.Skia;
using Microsoft.Maui.Graphics;
Now I am ready to get to image production! First, let's transform the spritesheet content into a pixel map (pixmap).
using MemoryStream ms = new MemoryStream(spriteContent);
SkiaSharp.SKBitmap bmp = SkiaSharp.SKBitmap.Decode(ms);
using var pixmap = bmp.PeekPixels();
Doing this enables us to transform the image from the parsed pixel layout. With the scaffolding there, let's now get the player service record:
Console.WriteLine("Getting player service record...");
var serviceRecord = (await client.StatsGetPlayerServiceRecord("ZeBond")).Result;
This, in turn, will give us a PlayerServiceRecord
instance that contains seasonal (or lifetime) player performance. Mission mostly accomplished.
Let's try and map the medals we have for a player to those that exist in game. To do that I wrote a loop that would go through medals in the service record and then identify where exactly in the spritesheet the medal is, extract it, get its binary representation, and then add the complete medal record to a generic list that we can use later to generate the snapshot image.
List<dynamic> medals = new List<dynamic>();
foreach(var medal in serviceRecord.CoreStats.Medals)
{
var matchedMedal = (from c in medalReferences.Medals where c.NameId == medal.NameId select c).FirstOrDefault();
if (matchedMedal != null)
{
dynamic medalReference = new ExpandoObject();
medalReference.Count = medal.Count;
// The spritesheet for medals is 16x16, so we want to make sure that we extract the right medals.
var row = (int)Math.Floor(matchedMedal.SpriteIndex / 16.0);
var column = (int)(matchedMedal.SpriteIndex % 16.0);
SkiaSharp.SKRectI rectI = SkiaSharp.SKRectI.Create(column * 256, row * 256, 256, 256);
var subset = pixmap.ExtractSubset(rectI);
using var data = subset.Encode(SkiaSharp.SKPngEncoderOptions.Default);
medalReference.ImageData = data.ToArray();
medalReference.Name = matchedMedal!.Name!.Value;
medals.Add(medalReference);
}
}
Now, let's generate the final image! To do that, here is the snippet:
// Now, let's compose all images.
SkiaBitmapExportContext context = new SkiaBitmapExportContext(256 * 16, (256 * (int)Math.Ceiling(medals.Count / 16.0)) + (16 * (int)Math.Ceiling(medals.Count / 16.0)), 1);
ICanvas canvas = context.Canvas;
SolidPaint solidPaint = new SolidPaint(Colors.White);
RectF solidRectangle = new RectF(0, 0, context.Width, context.Height);
canvas.SetFillPaint(solidPaint, solidRectangle);
canvas.FillRoundedRectangle(solidRectangle, 7);
int writeRow = 0;
int writeColumn = 0;
var orderedMedalList = medals.OrderByDescending(a => a.Count);
foreach(var reference in orderedMedalList)
{
using (MemoryStream mstream = new MemoryStream(reference.ImageData))
{
IImage image = Microsoft.Maui.Graphics.Skia.SkiaImage.FromStream(mstream);
canvas.DrawImage(image, writeColumn * 256, writeRow * 256, 256, 256);
canvas.Font = Font.DefaultBold;
canvas.FontSize = 21;
canvas.DrawString($"{reference.Name} ({reference.Count})", writeColumn * 256 + 128, (writeRow + 1) * 256 + 16, HorizontalAlignment.Center);
}
writeColumn++;
if (writeColumn == 16)
{
writeColumn = 0;
writeRow++;
}
}
context.WriteToFile("test_medals.jpg");
Console.WriteLine("Got all the player record
There's quite a bit happening here, so I will unpack the code. First, I am creating a new bitmap context that will be used to define the surface on which I will be drawing my final image. The size of the final image is defined here.
Specifically, I start with the width, which is 256 pixels per medal times 16 (the number of columns I want). The height is computed by multiplying 256 by the number of earned medals divided by 16. Keep in mind that the number of earned medals could be different than the number of total medals available in game since I very well might have quite a few that are tricky to get. The height value is then increased a bit more because under each medal I want to also add a label that shows the medal name and the count of earned medals.
Next, I paint a big white rectangle over the canvas because I don't want it to be transparent. You very well can skip this part if a transparent image is what you are looking for. Then, I set the counters for current row and columns to 0 (this is the starting position from which I start drawing) and then go through the list of earned medals (computed in the earlier step) and draw the pixels on the canvas, along with the properly positioned string containing the medal name and the count of said medals that the player earned.
In case the 16th column is reached, I reset it to zero and increase the row count. Finally, when the processing is complete I am writing the output to test_medals.jpg
. And the output looks something like this:
Congratulations - now you know how to generate a medal snapshot image with Orion!